From 52d533703ba83e40a95d1df7ef326f94e2510a09 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Thu, 11 Jun 2026 11:24:56 +0200 Subject: [PATCH] feat(tui): remake parts of the tui (#46) * feat(tui): remake parts of the tui * chore(tui): remove unused newViewport helper --- pkg/tui/account.go | 122 ++++++++++-------- pkg/tui/account_addresses.go | 2 +- pkg/tui/account_cards.go | 2 +- pkg/tui/account_orders.go | 2 +- pkg/tui/footer.go | 28 +++-- pkg/tui/header.go | 107 +++++++++------- pkg/tui/model.go | 10 +- pkg/tui/palette.go | 70 +++++++++++ pkg/tui/render_smoke_test.go | 111 ++++++++++++++++ pkg/tui/shop.go | 237 +++++++++++++++++------------------ pkg/tui/styles.go | 6 +- pkg/tui/update.go | 2 - pkg/tui/views.go | 15 ++- 13 files changed, 460 insertions(+), 254 deletions(-) create mode 100644 pkg/tui/palette.go create mode 100644 pkg/tui/render_smoke_test.go diff --git a/pkg/tui/account.go b/pkg/tui/account.go index 589afbf..c076321 100644 --- a/pkg/tui/account.go +++ b/pkg/tui/account.go @@ -8,36 +8,58 @@ import ( "terminalShop/pkg/models" ) -// updateAccountViewport initializes or resizes the detail viewport for the account page. -// Follows the reference pattern: each page owns its own viewport(s). -func (m Model) updateAccountViewport() Model { - // Match the reference: only subtract header + footer, no extra margins - // (account is a viewport page — views.go renders it without margin padding) +// accountBodyHeight returns the inner content height of the account body +// panels, mirroring the boxed shop layout: header + footer + 2 blank gap +// rows + 2 panel border rows are reserved. +func (m Model) accountBodyHeight() int { headerHeight := lipgloss.Height(m.BuildHeader()) footerHeight := lipgloss.Height(m.BuildFooter()) - verticalMarginHeight := headerHeight + footerHeight - - availableHeight := m.heightContainer - verticalMarginHeight - - leftWidth := 18 - rightWidth := m.widthContent - leftWidth - 2 - detailWidth := rightWidth - if m.size < large { - detailWidth = m.widthContent - // In stacked mode, reduce available height by the left panel - leftPanelHeight := len(models.AccountMenuItems) + 1 - availableHeight -= leftPanelHeight + h := m.heightContainer - headerHeight - footerHeight - 4 + if h < 3 { + h = 3 } - if availableHeight < 3 { - availableHeight = 3 + return h +} + +// accountDetailWidth returns the width of the unboxed detail area: the chrome +// width minus the menu panel and the gap. The detail side has no outer panel +// (its content draws its own boxes), so it reclaims the border + padding. +func (m Model) accountDetailWidth() int { + contentW, menuW, _, _ := m.shopLayout() + if m.size < large { + return contentW + } + return contentW - (menuW + 4) - 1 +} + +// accountTitleStyle styles a section title for the detail area: centered +// across the full detail width so it sits over the content below it. +func accountTitleStyle(width int) lipgloss.Style { + return pWht.Bold(true).Width(width).Align(lipgloss.Center) +} + +// updateAccountViewport initializes or resizes the detail viewport for the account page. +// The viewport spans the unboxed detail area to the right of the menu panel. +func (m Model) updateAccountViewport() Model { + detailW := m.accountDetailWidth() + // +1: no panel on the detail side, but one row is spent on the leading + // newline that aligns the content with the menu panel's first item row. + availableHeight := m.accountBodyHeight() + 1 + if m.size < large { + // Stacked: the menu panel sits above the detail area + menuPanelHeight := len(models.AccountMenuItems) + 2 + availableHeight -= menuPanelHeight + if availableHeight < 3 { + availableHeight = 3 + } } if !m.account.viewportReady { - m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight)) + m.account.detailViewport = viewport.New(viewport.WithWidth(detailW), viewport.WithHeight(availableHeight)) m.account.detailViewport.KeyMap = modifiedKeyMap m.account.viewportReady = true } else { - m.account.detailViewport.SetWidth(detailWidth) + m.account.detailViewport.SetWidth(detailW) m.account.detailViewport.SetHeight(availableHeight) } @@ -46,12 +68,7 @@ func (m Model) updateAccountViewport() Model { // getAccountDetailContent generates the content string for the current detail panel. func (m Model) getAccountDetailContent() string { - leftWidth := 18 - rightWidth := m.widthContent - leftWidth - 2 - detailContentWidth := rightWidth - 2 - if m.size < large { - detailContentWidth = m.widthContent - 2 - } + detailContentWidth := m.accountDetailWidth() if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) { return "" @@ -68,7 +85,7 @@ func (m Model) getAccountDetailContent() string { case "cards": return m.CardsView(detailContentWidth) case "faq": - titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2) + titleStyle := accountTitleStyle(detailContentWidth).MarginBottom(2) questionStyle := m.theme.TextHighlight().Bold(true) contentStyle := m.theme.TextBody().Width(detailContentWidth) faqContent := "" @@ -143,23 +160,22 @@ func (m Model) scrollToAccountDetailItem() Model { return m } -func (m Model) BuildAccountView(availableHeight int) string { - leftWidth := 18 +func (m Model) BuildAccountView() string { + _, menuW, _, _ := m.shopLayout() - // Build the left panel (menu items) - leftPanel := "" + // Left panel: menu items, pill highlight like the shop chrome + var menu []string for i, item := range models.AccountMenuItems { if m.account.cursor == i { - style := m.theme.Base().Background(m.theme.Highlight()).Foreground(m.theme.Accent()).Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left) - leftPanel += style.Render(item) + "\n" + menu = append(menu, lipgloss.NewStyle().Width(menuW). + Background(cPill).Foreground(cWhite).Bold(true). + Render(" "+item)) } else { - style := m.theme.TextBody().Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left) - leftPanel += style.Render(item) + "\n" + menu = append(menu, lipgloss.NewStyle().Width(menuW). + Render(" "+pBody.Render(item))) } } - - leftContainer := m.theme.Base(). - Width(leftWidth) + menuContent := lipgloss.JoinVertical(lipgloss.Left, menu...) // Generate detail content and set it on the viewport detailContent := m.getAccountDetailContent() @@ -168,25 +184,33 @@ func (m Model) BuildAccountView(availableHeight int) string { // Viewport handles scrolling/clipping — no manual line-slicing needed detailRendered := m.account.detailViewport.View() - // Responsive layout: side-by-side on large, stacked on small/medium + // Responsive layout: menu panel beside the unboxed detail area on large, + // stacked on small/medium. The detail side draws its own item boxes. + var body string if m.size < large { - return lipgloss.JoinVertical( + body = lipgloss.JoinVertical( lipgloss.Left, - leftContainer.Render(leftPanel), + panel(menuW, 0, menuContent), detailRendered, ) + } else { + bodyH := m.accountBodyHeight() + // Leading newline drops the detail content one row so its title + // lines up with the menu panel's first item, not its top border. + body = lipgloss.JoinHorizontal( + lipgloss.Top, + panel(menuW, bodyH, menuContent), + " ", + "\n"+detailRendered, + ) } - return lipgloss.JoinHorizontal( - lipgloss.Top, - leftContainer.Render(leftPanel), - " ", - detailRendered, - ) + // Blank rows separate the body from the header and footer boxes. + return "\n" + body + "\n" } func (m Model) AboutView(width int) string { - titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2) + titleStyle := accountTitleStyle(width).MarginBottom(2) contentStyle := m.theme.TextBody().Width(width) // USE this if wanting to add a corp name later // accentStyle := m.theme.TextHighlight().Bold(true) diff --git a/pkg/tui/account_addresses.go b/pkg/tui/account_addresses.go index c2117c2..3d55a98 100644 --- a/pkg/tui/account_addresses.go +++ b/pkg/tui/account_addresses.go @@ -1,7 +1,7 @@ package tui func (m Model) AddressesView(width int) string { - titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2) + titleStyle := accountTitleStyle(width).MarginBottom(2) contentStyle := m.theme.TextBody().Width(width) lines := titleStyle.Render("Address") + "\n\n" diff --git a/pkg/tui/account_cards.go b/pkg/tui/account_cards.go index 7ed610f..04d8b16 100644 --- a/pkg/tui/account_cards.go +++ b/pkg/tui/account_cards.go @@ -5,7 +5,7 @@ import ( ) func (m Model) CardsView(width int) string { - titleSTyle := m.theme.TextAccent().Bold(true).MarginBottom(2) + titleSTyle := accountTitleStyle(width).MarginBottom(2) contentStyle := m.theme.TextBody().Width(width) lines := titleSTyle.Render("Cards") + "\n\n" diff --git a/pkg/tui/account_orders.go b/pkg/tui/account_orders.go index 280ad5b..369136d 100644 --- a/pkg/tui/account_orders.go +++ b/pkg/tui/account_orders.go @@ -66,7 +66,7 @@ func (m Model) currentOrderList() []models.Order { } func (m Model) orderListView(title string, orders []models.Order, width int) string { - titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1) + titleStyle := accountTitleStyle(width).MarginBottom(1) contentStyle := m.theme.TextBody().Width(width) if !m.OrdersLoaded { diff --git a/pkg/tui/footer.go b/pkg/tui/footer.go index 5709549..00f3102 100644 --- a/pkg/tui/footer.go +++ b/pkg/tui/footer.go @@ -6,20 +6,26 @@ import ( "charm.land/lipgloss/v2" ) +// BuildFooter renders the bottom chrome bar with the current page keybinds. +// Layout ported from the design prototype at ~/programming/test/shop.go. func (m Model) BuildFooter() string { - keybindStyle := m.theme.TextDim().Bold(true) - descStyle := m.theme.TextDim() + _, _, _, wideW := m.shopLayout() + item := func(k, v string) string { + return pBody.Render(k) + " " + pDim.Render(v) + } + + var keys string if m.size == small { - footerText := keybindStyle.Render("m") + " " + descStyle.Render("menu") - return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText) + keys = item("m", "menu") + } else { + parts := make([]string, 0, len(m.footer)) + for _, cmd := range m.footer { + parts = append(parts, item(cmd.key, cmd.value)) + } + keys = strings.Join(parts, pDim.Render(" · ")) } - parts := []string{} - for _, cmd := range m.footer { - parts = append(parts, keybindStyle.Render(cmd.key)+" "+descStyle.Render(cmd.value)) - } - footerText := strings.Join(parts, " ") - - return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText) + row := lipgloss.NewStyle().Width(wideW).Align(lipgloss.Center).Render(keys) + return panel(wideW, 0, row) } diff --git a/pkg/tui/header.go b/pkg/tui/header.go index 44a620a..234b5a8 100644 --- a/pkg/tui/header.go +++ b/pkg/tui/header.go @@ -2,57 +2,72 @@ package tui import ( "fmt" + "image/color" + "strings" "charm.land/lipgloss/v2" - "charm.land/lipgloss/v2/table" ) -func (m Model) BuildHeader() string { - bold := m.theme.TextAccent().Bold(true).Render - accent := m.theme.TextAccent().Render - base := m.theme.Base().Render - cursor := m.theme.Base().Background(m.theme.Brand()).Render(" ") - - mark := bold("t") + cursor - logo := bold("terminal") - menu := bold("m") + base(" ☰") - - tab := func(key, name string, active bool) string { - if active { - return accent(key + " " + name) +// cartBlendColor mixes the colors of all cart items, weighted by quantity, +// so the header price tints toward whatever dominates the cart. +func (m Model) cartBlendColor() color.Color { + var rSum, gSum, bSum, n int + for _, item := range m.Cart { + r, g, b, ok := parseHexColor(item.Coffee.Color) + if !ok { + continue } - return accent(key) + base(" "+name) + rSum += r * item.Quantity + gSum += g * item.Quantity + bSum += b * item.Quantity + n += item.Quantity } - shop := tab("s", "shop", m.currentPage == shopPage) - account := tab("a", "account", m.currentPage == accountPage) - - priceStr := fmt.Sprintf(" $%2v", m.CalculateSubtotal()/100) - countStr := fmt.Sprintf(" [%d]", m.CartItemCount()) - cartLabel := base(" cart") - if m.inCartFlow() { - cartLabel = accent(" cart") + if n == 0 { + return cWhite } - cart := accent("c") + cartLabel + accent(priceStr) + base(countStr) - - var tabs []string - switch m.size { - case small: - tabs = []string{mark, cart} - case medium: - tabs = []string{menu, logo, cart} - default: - tabs = []string{logo, shop, account, cart} - } - - tbl := table.New().Border(lipgloss.NormalBorder()).BorderStyle(lipgloss.NewStyle().Foreground(m.theme.Border())).Row(tabs...).Width(m.widthContent).StyleFunc(func(row, col int) lipgloss.Style { - return m.theme.Base().Padding(0, 1).AlignHorizontal(lipgloss.Center) - }).Render() - - return lipgloss.Place( - m.widthContainer, - lipgloss.Height(tbl), - lipgloss.Center, - lipgloss.Center, - tbl, - ) + return lipgloss.Color(fmt.Sprintf("#%02X%02X%02X", rSum/n, gSum/n, bSum/n)) +} + +// BuildHeader renders the top chrome bar: brand, tab pills, and cart summary. +// Layout ported from the design prototype at ~/programming/test/shop.go. +func (m Model) BuildHeader() string { + _, _, _, wideW := m.shopLayout() + + brand := pWht.Bold(true).Render("terminal") + + pill := lipgloss.NewStyle().Background(cPill).Foreground(cWhite).Bold(true).Padding(0, 1) + tab := func(key, label string, active bool) string { + if active { + return pill.Render(label) + } + return pDim.Render(key+" ") + pBody.Render(label) + } + + var left string + if m.size == small { + left = brand + } else { + left = strings.Join([]string{ + brand, + tab("s", "shop", m.currentPage == shopPage), + tab("a", "account", m.currentPage == accountPage), + }, " ") + } + + cartLabel := pDim.Render("cart ") + if m.inCartFlow() { + cartLabel = pWht.Bold(true).Render("cart ") + } + cart := cartLabel + + lipgloss.NewStyle().Foreground(m.cartBlendColor()).Bold(true). + Render(fmt.Sprintf("$%d", m.CalculateSubtotal()/100)) + + pDim.Render(fmt.Sprintf(" · %d", m.CartItemCount())) + + gap := wideW - lipgloss.Width(left) - lipgloss.Width(cart) + if gap < 1 { + gap = 1 + } + row := left + strings.Repeat(" ", gap) + cart + + return panel(wideW, 0, row) } diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 7923910..2cd61e1 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -142,10 +142,7 @@ type Model struct { } type shopState struct { - selected int - menuViewport viewport.Model - detailViewport viewport.Model - viewportsReady bool + selected int } type splashState struct { @@ -240,7 +237,6 @@ func (m Model) SwitchPage(p page) Model { func (m Model) ShopSwitch() (Model, tea.Cmd) { m = m.SwitchPage(shopPage) - m = m.updateShopViewports() m.footer = []footerCommand{ {key: "j/k", value: "products"}, {key: "+/-", value: "qty"}, @@ -930,10 +926,6 @@ func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clien return m } -func newViewport(w, h int) viewport.Model { - return viewport.New(viewport.WithWidth(w), viewport.WithHeight(h)) -} - func (m Model) collectCardCmd() tea.Cmd { return func() tea.Msg { if m.APIClient == nil { diff --git a/pkg/tui/palette.go b/pkg/tui/palette.go new file mode 100644 index 0000000..104d775 --- /dev/null +++ b/pkg/tui/palette.go @@ -0,0 +1,70 @@ +package tui + +import ( + "image/color" + "strconv" + "strings" + + "charm.land/lipgloss/v2" +) + +// Palette and base styles for the boxed shop chrome. +// Ported from the design prototype at ~/programming/test/shop.go. +var ( + cWhite = lipgloss.Color("#EAEAEA") + cBody = lipgloss.Color("#9A9A9A") + cDim = lipgloss.Color("#5A5A5A") + cLine = lipgloss.Color("#3A3A3A") + cInk = lipgloss.Color("#161616") // text on a light colored block + cGreen = lipgloss.Color("#4FB477") + cRed = lipgloss.Color("#E06C6C") + cGold = lipgloss.Color("#D0962E") // single accent + cPill = lipgloss.Color("#2E2E2E") // active-tab pill bg + + pBody = lipgloss.NewStyle().Foreground(cBody) + pDim = lipgloss.NewStyle().Foreground(cDim) + pWht = lipgloss.NewStyle().Foreground(cWhite) +) + +// parseHexColor splits a "#RRGGBB" string into its channel values. +func parseHexColor(s string) (r, g, b int, ok bool) { + s = strings.TrimPrefix(s, "#") + if len(s) != 6 { + return 0, 0, 0, false + } + v, err := strconv.ParseUint(s, 16, 32) + if err != nil { + return 0, 0, 0, false + } + return int(v >> 16), int((v >> 8) & 0xFF), int(v & 0xFF), true +} + +// contrastColor returns ink for light backgrounds and white for dark ones, +// using perceived luminance so text stays readable on any product color. +func contrastColor(hex string) color.Color { + r, g, b, ok := parseHexColor(hex) + if !ok { + return cWhite + } + lum := (299*r + 587*g + 114*b) / 1000 + if lum > 140 { + return cInk + } + return cWhite +} + +// panel wraps content in a rounded box. textW is the inner text width and +// innerH the inner content height; the total render size is textW+4 by +// innerH+2. Ported from the design prototype at ~/programming/test/shop.go, +// adjusted for lipgloss v2 where Width/Height include the border. +func panel(textW, innerH int, content string) string { + s := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(cLine). + Padding(0, 1). + Width(textW + 4) + if innerH > 0 { + s = s.Height(innerH + 2) + } + return s.Render(content) +} diff --git a/pkg/tui/render_smoke_test.go b/pkg/tui/render_smoke_test.go new file mode 100644 index 0000000..74823be --- /dev/null +++ b/pkg/tui/render_smoke_test.go @@ -0,0 +1,111 @@ +package tui + +import ( + "regexp" + "strings" + "testing" + + "terminalShop/pkg/models" +) + +var ansiRE = regexp.MustCompile("\x1b\\[[0-9;]*m") + +// TestRenderShopSmoke prints the rendered shop frame so chrome alignment can +// be inspected, and asserts that the header, body, and footer boxes share the +// same left and right edges. +func TestRenderShopSmoke(t *testing.T) { + m := NewModel("smoke") + m.Loading = false + m.Cart[m.Coffees[0].ID] = &models.CartItem{CoffeeID: m.Coffees[0].ID, Coffee: m.Coffees[0], Quantity: 4} + m = m.updateLayout(100, 40) + m, _ = m.ShopSwitch() + + out := m.renderString() + plain := ansiRE.ReplaceAllString(out, "") + t.Log("\n" + plain) + + // Collect left/right border columns of every box-drawing row. + var lefts, rights []int + for _, line := range strings.Split(plain, "\n") { + runes := []rune(line) + first, last := -1, -1 + for i, r := range runes { + if strings.ContainsRune("╭╮╰╯│", r) { + if first == -1 { + first = i + } + last = i + } + } + if first != -1 { + lefts = append(lefts, first) + rights = append(rights, last) + } + } + if len(lefts) == 0 { + t.Fatal("no box-drawing rows found") + } + for i := range lefts { + if lefts[i] != lefts[0] { + t.Errorf("left edge misaligned: row %d at col %d, expected %d", i, lefts[i], lefts[0]) + break + } + if rights[i] != rights[0] { + t.Errorf("right edge misaligned: row %d at col %d, expected %d", i, rights[i], rights[0]) + break + } + } +} + +// TestRenderAccountSmoke verifies the account page boxes stay within the +// header/footer chrome bounds. The detail area is unboxed, so inner boxes may +// be narrower than the chrome but must never poke outside it. +func TestRenderAccountSmoke(t *testing.T) { + m := NewModel("smoke") + m.Loading = false + m = m.updateLayout(100, 40) + m, _ = m.AccountSwitch() + + plain := ansiRE.ReplaceAllString(m.renderString(), "") + t.Log("\n" + plain) + + var lefts, rights []int + for _, line := range strings.Split(plain, "\n") { + runes := []rune(line) + first, last := -1, -1 + for i, r := range runes { + if strings.ContainsRune("╭╮╰╯│", r) { + if first == -1 { + first = i + } + last = i + } + } + if first != -1 { + lefts = append(lefts, first) + rights = append(rights, last) + } + } + if len(lefts) == 0 { + t.Fatal("no box-drawing rows found") + } + // Row 0 is the header box: it defines the chrome bounds. + for i := range lefts { + if lefts[i] < lefts[0] || rights[i] > rights[0] { + t.Errorf("box outside chrome: row %d spans %d-%d, chrome is %d-%d", + i, lefts[i], rights[i], lefts[0], rights[0]) + break + } + } +} + +// TestRenderShopSmokeStacked prints the stacked (medium/small) layouts. +func TestRenderShopSmokeStacked(t *testing.T) { + for _, size := range [][2]int{{60, 32}, {45, 30}} { + m := NewModel("smoke") + m.Loading = false + m = m.updateLayout(size[0], size[1]) + m, _ = m.ShopSwitch() + t.Logf("--- %dx%d ---\n%s", size[0], size[1], ansiRE.ReplaceAllString(m.renderString(), "")) + } +} diff --git a/pkg/tui/shop.go b/pkg/tui/shop.go index 3a7aac8..09f7cc7 100644 --- a/pkg/tui/shop.go +++ b/pkg/tui/shop.go @@ -4,163 +4,169 @@ import ( "fmt" "strings" - "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "terminalShop/pkg/models" ) -func (m Model) calculateShopWidths() (int, int) { - menuWidth := 14 - if len(m.Coffees) == 0 { - if m.size < large { - return m.widthContent, m.widthContent - } - return menuWidth, m.widthContent - menuWidth - } - for _, c := range m.Coffees { - if w := lipgloss.Width(c.Name); w > menuWidth { - menuWidth = w - } - } - if menuWidth > 0 { - menuWidth += 4 +const ( + // shopBodyInnerHeight fixes the inner height of the shop body panels so + // the detail box never resizes with the description. + shopBodyInnerHeight = 13 + // shopDescLines pins the description region to an exact line count. + shopDescLines = 3 +) + +// shopLayout returns the shared chrome widths, all derived from one content +// width so the header, body panels, and footer line up exactly. +// Math ported from the design prototype at ~/programming/test/shop.go: +// menu(menuW+4) + gap(1) + detail(detailW+4) = contentW = wideW + 4. +func (m Model) shopLayout() (contentW, menuW, detailW, wideW int) { + contentW = m.widthContent + if contentW > 74 { + contentW = 74 } + wideW = contentW - 4 if m.size < large { - menuWidth = m.widthContent + // Stacked layout: both panels span the full content width. + return contentW, wideW, wideW, wideW } - detailWidth := m.widthContent - menuWidth - if m.size < large { - detailWidth = m.widthContent - } - return menuWidth, detailWidth + menuW = 16 + detailW = contentW - 25 + return contentW, menuW, detailW, wideW } -func (m Model) updateShopViewports() Model { - headerHeight := lipgloss.Height(m.BuildHeader()) - footerHeight := lipgloss.Height(m.BuildFooter()) - availableHeight := m.heightContainer - headerHeight - footerHeight - - if len(m.Coffees) == 0 { - return m +// roastLevel maps a roast type to a 1-5 intensity for the roast meter. +func roastLevel(roastType string) int { + rt := strings.ToLower(roastType) + switch { + case strings.Contains(rt, "dark"): + return 5 + case strings.Contains(rt, "light"): + return 2 + default: + return 3 } - - menuWidth, detailWidth := m.calculateShopWidths() - - if !m.shop.viewportsReady { - m.shop.menuViewport = newViewport(menuWidth, availableHeight) - m.shop.menuViewport.KeyMap = viewport.KeyMap{} // menu nav is manual - m.shop.detailViewport = newViewport(detailWidth, availableHeight) - m.shop.detailViewport.KeyMap = modifiedKeyMap - m.shop.viewportsReady = true - } else { - m.shop.menuViewport.SetWidth(menuWidth) - m.shop.menuViewport.SetHeight(availableHeight) - m.shop.detailViewport.SetWidth(detailWidth) - m.shop.detailViewport.SetHeight(availableHeight) - } - return m } -func (m Model) getShopMenuContent() string { - menuWidth, _ := m.calculateShopWidths() - - selectedColor := m.theme.Highlight() - if m.shop.selected >= 0 && m.shop.selected < len(m.Coffees) { - if c := m.Coffees[m.shop.selected].Color; c != "" { - selectedColor = lipgloss.Color(c) - } +// clampLines pads or truncates rendered text to exactly n lines so regions +// like the description never change the panel height. +func clampLines(s string, n int) string { + lines := strings.Split(s, "\n") + if len(lines) > n { + lines = lines[:n] } - - var normal, highlighted lipgloss.Style - if m.size < large { - menuWidth = m.widthContent - normal = m.theme.TextBody(). - Width(menuWidth - 1).Align(lipgloss.Center) - highlighted = m.theme.TextAccent(). - Width(menuWidth - 1).Align(lipgloss.Center). - Background(selectedColor).Bold(true) - } else { - normal = m.theme.TextBody(). - Width(menuWidth+2).Padding(0, 1) - highlighted = m.theme.TextAccent(). - Width(menuWidth+2).Padding(0, 1). - Background(selectedColor).Bold(true) + for len(lines) < n { + lines = append(lines, "") } + return strings.Join(lines, "\n") +} +// roastMeter renders the strength dots, e.g. ● ● ● ○ ○ +func roastMeter(n int) string { + on := lipgloss.NewStyle().Foreground(cGold).Render(strings.Repeat("● ", n)) + off := pDim.Render(strings.Repeat("○ ", 5-n)) + return strings.TrimRight(on+off, " ") +} + +func (m Model) shopMenuContent(w int) string { var b strings.Builder for i, c := range m.Coffees { if i == m.shop.selected { - b.WriteString(highlighted.Render(c.Name) + "\n") + b.WriteString(lipgloss.NewStyle().Width(w). + Background(lipgloss.Color(c.Color)). + Foreground(contrastColor(c.Color)). + Bold(true). + Render(" · " + c.Name)) } else { - b.WriteString(normal.Render(c.Name) + "\n") + dot := lipgloss.NewStyle().Foreground(lipgloss.Color(c.Color)).Render("·") + b.WriteString(lipgloss.NewStyle().Width(w). + Render(" " + dot + " " + pBody.Render(c.Name))) + } + if i < len(m.Coffees)-1 { + b.WriteString("\n") } } - return lipgloss.NewStyle().Padding(0, 1).Render(b.String()) + return b.String() } -func (m Model) getShopDetailContent() string { - if len(m.Coffees) == 0 { - return "" - } +func (m Model) shopDetailContent(w int) string { coffee := m.Coffees[m.shop.selected] - _, detailWidth := m.calculateShopWidths() - if m.size >= large { - detailWidth -= 2 - } - quantity := 0 if item, exists := m.Cart[coffee.ID]; exists { quantity = item.Quantity } - detail := lipgloss.JoinVertical(lipgloss.Left, - m.theme.TextAccent().Bold(true).Render(coffee.Name), - m.theme.TextBody().Render( - fmt.Sprintf("%s | %doz | %s", coffee.RoastType, coffee.Ounces, coffee.BeanType), - ), + name := pWht.Bold(true).Render(coffee.Name) + + sep := pDim.Render(" · ") + spec := pBody.Render(strings.ToUpper(coffee.RoastType)) + sep + + pDim.Render(fmt.Sprintf("%dOZ", coffee.Ounces)) + sep + + pDim.Render(strings.ToUpper(coffee.BeanType)) + + strength := pDim.Render("roast ") + roastMeter(roastLevel(coffee.RoastType)) + + price := lipgloss.NewStyle().Foreground(lipgloss.Color(coffee.Color)).Bold(true). + Render(fmt.Sprintf("$%d.%02d", coffee.Price/100, coffee.Price%100)) + + pDim.Render(" usd") + + // Stock is not modeled in the backend yet, so the indicator is static. + stock := lipgloss.NewStyle().Foreground(cGreen).Render("● in stock") + + desc := clampLines(pBody.Width(w).Render(coffee.Description), shopDescLines) + + // stepper: git-diff coloring — red minus, green plus + minus := lipgloss.NewStyle().Foreground(cRed).Bold(true).Render("−") + plus := lipgloss.NewStyle().Foreground(cGreen).Bold(true).Render("+") + count := lipgloss.NewStyle().Foreground(cWhite).Bold(true). + Padding(0, 2).Render(fmt.Sprintf("%d", quantity)) + stepper := pDim.Render("qty ") + minus + count + plus + + return lipgloss.JoinVertical(lipgloss.Left, + name, + spec, + strength, "", - lipgloss.NewStyle().Foreground(lipgloss.Color(coffee.Color)).Bold(true).Render( - fmt.Sprintf("$%.2f", float64(coffee.Price)/100), - ), + price+" "+stock, "", - m.theme.TextAccent().Width(detailWidth).Render(coffee.Description), + desc, "", - fmt.Sprintf("- %d +", quantity), + stepper, ) - return lipgloss.NewStyle().Width(detailWidth).Render(detail) } func (m Model) ShopView() string { - if !m.shop.viewportsReady { - m = m.updateShopViewports() - } if len(m.Coffees) == 0 { return lipgloss.Place(m.widthContent, m.heightContainer, lipgloss.Center, lipgloss.Center, "No products available.") } - m.shop.menuViewport.SetContent(m.getShopMenuContent()) - m.shop.detailViewport.SetContent(m.getShopDetailContent()) + _, menuW, detailW, _ := m.shopLayout() + mc := m.shopMenuContent(menuW) + dc := m.shopDetailContent(detailW) + + var body string if m.size < large { - return lipgloss.JoinVertical(lipgloss.Top, - m.shop.menuViewport.View(), - m.shop.detailViewport.View(), + body = lipgloss.JoinVertical(lipgloss.Left, + panel(menuW, 0, mc), + panel(detailW, 0, dc), + ) + } else { + bodyH := max(shopBodyInnerHeight, lipgloss.Height(mc), lipgloss.Height(dc)) + body = lipgloss.JoinHorizontal(lipgloss.Top, + panel(menuW, bodyH, mc), + " ", + panel(detailW, bodyH, dc), ) } - return lipgloss.JoinHorizontal(lipgloss.Top, - m.shop.menuViewport.View(), - " ", - m.shop.detailViewport.View(), - ) + + // Blank rows separate the body from the header and footer boxes. + return "\n" + body + "\n" } func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) { - var cmd tea.Cmd - var cmds []tea.Cmd - if len(m.Coffees) == 0 { return m, nil } @@ -197,16 +203,7 @@ func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) { } } - // Pass messages to the detail viewport so pgup/pgdn scroll works - m.shop.detailViewport, cmd = m.shop.detailViewport.Update(msg) - cmds = append(cmds, cmd) - - if m.shop.viewportsReady { - m.shop.menuViewport.SetContent(m.getShopMenuContent()) - m.shop.detailViewport.SetContent(m.getShopDetailContent()) - } - - return m, tea.Batch(cmds...) + return m, nil } func (m Model) shopMoveSelected(previous bool) Model { @@ -223,11 +220,5 @@ func (m Model) shopMoveSelected(previous bool) Model { next = len(m.Coffees) - 1 } m.shop.selected = next - - if m.shop.viewportsReady { - targetY := (m.shop.selected + 2) * 1 - m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height()/2) - m.shop.detailViewport.GotoTop() - } return m } diff --git a/pkg/tui/styles.go b/pkg/tui/styles.go index c42ad32..0549054 100644 --- a/pkg/tui/styles.go +++ b/pkg/tui/styles.go @@ -8,13 +8,13 @@ import ( func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string { padded := lipgloss.PlaceHorizontal(totalWidth, position, content) - base := m.theme.Base().Border(lipgloss.NormalBorder()).Width(totalWidth) + base := m.theme.Base().Border(lipgloss.RoundedBorder()).Width(totalWidth) var style lipgloss.Style if selected { - style = base.BorderForeground(m.theme.Highlight()) + style = base.BorderForeground(cWhite) } else { - style = base.BorderForeground(m.theme.Border()) + style = base.BorderForeground(cLine) } return style.Padding(paddingV, paddingH).Render(padded) } diff --git a/pkg/tui/update.go b/pkg/tui/update.go index 84888db..5981dd3 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -16,8 +16,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if tick.seq == m.resizeSeq { m = m.updateLayout(m.pendingWidth, m.pendingHeight) switch m.currentPage { - case shopPage: - m = m.updateShopViewports() case cartPage: m = m.updateCartViewport() case shippingPage: diff --git a/pkg/tui/views.go b/pkg/tui/views.go index a68f512..d595e53 100644 --- a/pkg/tui/views.go +++ b/pkg/tui/views.go @@ -62,7 +62,8 @@ func (m Model) renderString() string { if breadcrumbs != "" { breadcrumbsHeight = lipgloss.Height(breadcrumbs) } - footerHeight := 1 + footer := m.BuildFooter() + footerHeight := lipgloss.Height(footer) marginTop := 1 marginBottom := 1 bufferSpace := 1 @@ -87,17 +88,15 @@ func (m Model) renderString() string { errorStyle = errorStyle.Width(m.widthContent - 4) } errorBanner := errorStyle.Render(m.error.message) - content = errorBanner + "\n" + m.buildPageContent(availableContentHeight) + content = errorBanner + "\n" + m.buildPageContent() } else if m.notice != nil { noticeStyle := m.theme.TextSuccess().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Success()).Padding(0, 1).MarginBottom(1) noticeBanner := noticeStyle.Render(m.notice.message) - content = noticeBanner + "\n" + m.buildPageContent(availableContentHeight) + content = noticeBanner + "\n" + m.buildPageContent() } else { - content = m.buildPageContent(availableContentHeight) + content = m.buildPageContent() } - footer := m.BuildFooter() - isViewportPage := m.currentPage == shopPage || m.currentPage == cartPage || m.currentPage == shippingPage || m.currentPage == paymentPage || m.currentPage == confirmPage || m.currentPage == accountPage @@ -137,10 +136,10 @@ func (m Model) renderString() string { ) } -func (m Model) buildPageContent(height int) string { +func (m Model) buildPageContent() string { switch m.currentPage { case accountPage: - return m.BuildAccountView(height) + return m.BuildAccountView() case shippingPage: return m.ShippingPageView() case paymentPage: