From 5dd22fb8be055279a549fcd4de402501d20b5235 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Thu, 11 Jun 2026 15:53:48 +0200 Subject: [PATCH] style(tui): update rest of tui to new ui pattern (#47) --- pkg/tui/account.go | 90 +++++++++++++++++++------------ pkg/tui/account_addresses.go | 3 +- pkg/tui/account_cards.go | 3 +- pkg/tui/account_orders.go | 102 ++++++++++++++++++++++++++--------- pkg/tui/cart.go | 27 +++++----- pkg/tui/model.go | 6 +-- pkg/tui/render_smoke_test.go | 83 ++++++++++++++++++++++++++++ 7 files changed, 237 insertions(+), 77 deletions(-) diff --git a/pkg/tui/account.go b/pkg/tui/account.go index c076321..62cc47f 100644 --- a/pkg/tui/account.go +++ b/pkg/tui/account.go @@ -1,6 +1,8 @@ package tui import ( + "strings" + "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -9,16 +11,18 @@ import ( ) // 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. +// panels. The body is quantized to whole order cards so the menu panel's +// bottom border lines up exactly with the last visible card; the two gap +// rows around the body host the up/down scroll arrows. func (m Model) accountBodyHeight() int { headerHeight := lipgloss.Height(m.BuildHeader()) footerHeight := lipgloss.Height(m.BuildFooter()) - h := m.heightContainer - headerHeight - footerHeight - 4 - if h < 3 { - h = 3 + budget := m.heightContainer - headerHeight - footerHeight - 2 + cards := budget / orderCardHeight + if cards < 1 { + cards = 1 } - return h + return cards*orderCardHeight - 2 } // accountDetailWidth returns the width of the unboxed detail area: the chrome @@ -32,19 +36,13 @@ func (m Model) accountDetailWidth() int { 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 + // +2: no panel on the detail side, so the viewport spans the same rows + // as the menu panel including its border rows — tops and bottoms align. + availableHeight := m.accountBodyHeight() + 2 if m.size < large { // Stacked: the menu panel sits above the detail area menuPanelHeight := len(models.AccountMenuItems) + 2 @@ -85,7 +83,6 @@ func (m Model) getAccountDetailContent() string { case "cards": return m.CardsView(detailContentWidth) case "faq": - titleStyle := accountTitleStyle(detailContentWidth).MarginBottom(2) questionStyle := m.theme.TextHighlight().Bold(true) contentStyle := m.theme.TextBody().Width(detailContentWidth) faqContent := "" @@ -96,7 +93,7 @@ func (m Model) getAccountDetailContent() string { faqContent += "\n\n" } } - detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent + detailView := faqContent if !m.account.faqFocused { hintStyle := m.theme.TextDim() detailView += "\n\n" + hintStyle.Render("enter: scroll faq") @@ -120,19 +117,25 @@ func (m Model) scrollToAccountDetailItem() Model { switch { case m.account.orderViewState == 1: - // No title in list mode, cards joined with JoinVertical (no gaps) - // Card: border(2) + content(2) + PaddingLeft only = 4 lines - headerOffset = 0 - itemHeight = 4 - itemCount = len(m.currentOrderList()) - selectedIndex = m.account.orderCursor + // Order list uses whole-card windowing instead of viewport scroll: + // shift the window just enough to keep the cursor's card visible. + page := m.orderListPageSize() + if m.account.orderCursor < m.account.orderWindowStart { + m.account.orderWindowStart = m.account.orderCursor + } + if m.account.orderCursor >= m.account.orderWindowStart+page { + m.account.orderWindowStart = m.account.orderCursor - page + 1 + } + m.account.detailViewport.GotoTop() + return m case m.account.addressListFocused: - headerOffset = 3 + // Titles were removed from the detail views, lists start at the top + headerOffset = 0 itemHeight = 5 itemCount = len(m.SavedAddresses) selectedIndex = m.account.addressCursor case m.account.cardListFocused: - headerOffset = 3 + headerOffset = 0 itemHeight = 4 itemCount = len(m.SavedCards) selectedIndex = m.account.cardCursor @@ -163,12 +166,20 @@ func (m Model) scrollToAccountDetailItem() Model { func (m Model) BuildAccountView() string { _, menuW, _, _ := m.shopLayout() - // Left panel: menu items, pill highlight like the shop chrome + // Left panel: menu items. The highlight is blue while the user navigates + // the menu, and falls back to the gray pill when focus has moved into the + // detail area (browsing orders, managing addresses/cards, scrolling faq). + focusedInside := m.account.orderViewState > 0 || m.account.addressListFocused || + m.account.cardListFocused || m.account.faqFocused + pillBg := m.theme.Highlight() + if focusedInside { + pillBg = cPill + } var menu []string for i, item := range models.AccountMenuItems { if m.account.cursor == i { menu = append(menu, lipgloss.NewStyle().Width(menuW). - Background(cPill).Foreground(cWhite).Bold(true). + Background(pillBg).Foreground(cWhite).Bold(true). Render(" "+item)) } else { menu = append(menu, lipgloss.NewStyle().Width(menuW). @@ -195,22 +206,32 @@ func (m Model) BuildAccountView() string { ) } 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, + detailRendered, ) } - // Blank rows separate the body from the header and footer boxes. - return "\n" + body + "\n" + // Blank rows separate the body from the header and footer boxes. When the + // windowed order list has hidden items, the arrows occupy those gap rows, + // centered over the card column, so the cards themselves always span + // exactly the menu panel's rows — top and bottom borders aligned. + topGap := "" + if above := m.orderHiddenAbove(); above > 0 && m.size >= large { + topGap = strings.Repeat(" ", menuW+5) + + orderScrollIndicator("↑", above, m.accountDetailWidth()) + } + bottomGap := "" + if below := m.orderHiddenBelow(); below > 0 && m.size >= large { + bottomGap = strings.Repeat(" ", menuW+5) + + orderScrollIndicator("↓", below, m.accountDetailWidth()) + } + return topGap + "\n" + body + "\n" + bottomGap } func (m Model) AboutView(width int) string { - 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) @@ -232,7 +253,7 @@ func (m Model) AboutView(width int) string { width, )) + "\n" - return titleStyle.Render("About") + "\n\n" + aboutContent + return aboutContent } func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { @@ -359,6 +380,7 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { case 0: m.account.orderViewState = 1 m.account.orderCursor = 0 + m.account.orderWindowStart = 0 m.account.detailViewport.GotoTop() m.footer = []footerCommand{ {key: "j/k", value: "orders"}, diff --git a/pkg/tui/account_addresses.go b/pkg/tui/account_addresses.go index 3d55a98..fdaa8cd 100644 --- a/pkg/tui/account_addresses.go +++ b/pkg/tui/account_addresses.go @@ -1,10 +1,9 @@ package tui func (m Model) AddressesView(width int) string { - titleStyle := accountTitleStyle(width).MarginBottom(2) contentStyle := m.theme.TextBody().Width(width) - lines := titleStyle.Render("Address") + "\n\n" + lines := "" if len(m.SavedAddresses) == 0 { lines += contentStyle.Render("No saved addresses") } else { diff --git a/pkg/tui/account_cards.go b/pkg/tui/account_cards.go index 04d8b16..51a18d5 100644 --- a/pkg/tui/account_cards.go +++ b/pkg/tui/account_cards.go @@ -5,10 +5,9 @@ import ( ) func (m Model) CardsView(width int) string { - titleSTyle := accountTitleStyle(width).MarginBottom(2) contentStyle := m.theme.TextBody().Width(width) - lines := titleSTyle.Render("Cards") + "\n\n" + lines := "" if m.SavedCardsIsEmpty() { lines += contentStyle.Render("No saved cards") } else { diff --git a/pkg/tui/account_orders.go b/pkg/tui/account_orders.go index 369136d..bef0e6c 100644 --- a/pkg/tui/account_orders.go +++ b/pkg/tui/account_orders.go @@ -10,11 +10,11 @@ import ( ) func (m Model) OrdersView(width int) string { - return m.orderListView("Order History", m.HistoryOrders(), width) + return m.orderListView(m.HistoryOrders(), width) } func (m Model) ActiveOrdersView(width int) string { - return m.orderListView("Active Orders", m.ActiveOrders(), width) + return m.orderListView(m.ActiveOrders(), width) } func (m Model) ActiveOrders() []models.Order { @@ -65,41 +65,95 @@ func (m Model) currentOrderList() []models.Order { return nil } -func (m Model) orderListView(title string, orders []models.Order, width int) string { - titleStyle := accountTitleStyle(width).MarginBottom(1) +// orderCardHeight is the rendered height of one order card: +// border (2) + content lines (2), no vertical padding. +const orderCardHeight = 4 + +// orderListPageSize returns how many whole order cards fit in the detail +// area. Both scroll arrows render outside the list, in the gap rows above +// and below the body, so the cards use the full viewport height. +func (m Model) orderListPageSize() int { + n := m.account.detailViewport.Height() / orderCardHeight + if n < 1 { + n = 1 + } + return n +} + +// orderHiddenAbove returns how many order cards are scrolled off above the +// windowed list, or 0 when the windowed order list isn't showing. +func (m Model) orderHiddenAbove() int { + if m.account.orderViewState != 1 || !m.OrdersLoaded { + return 0 + } + orders := m.currentOrderList() + if len(orders) == 0 { + return 0 + } + page := m.orderListPageSize() + start := m.account.orderWindowStart + if start > len(orders)-page { + start = len(orders) - page + } + if start < 0 { + start = 0 + } + return start +} + +// orderHiddenBelow returns how many order cards are scrolled off below the +// windowed list, or 0 when the windowed order list isn't showing. +func (m Model) orderHiddenBelow() int { + if m.account.orderViewState == 2 || !m.OrdersLoaded { + return 0 + } + orders := m.currentOrderList() + if len(orders) == 0 { + return 0 + } + below := len(orders) - (m.orderHiddenAbove() + m.orderListPageSize()) + if below < 0 { + below = 0 + } + return below +} + +// orderScrollIndicator renders the dim "more items" arrow row, centered +// across the card width. +func orderScrollIndicator(arrow string, count, width int) string { + return pDim.Width(width).Align(lipgloss.Center). + Render(fmt.Sprintf("%s %d more", arrow, count)) +} + +func (m Model) orderListView(orders []models.Order, width int) string { contentStyle := m.theme.TextBody().Width(width) if !m.OrdersLoaded { - return titleStyle.Render(title) + "\n" + contentStyle.Render("Loading orders...") + return contentStyle.Render("Loading orders...") } if len(orders) == 0 { - return titleStyle.Render(title) + "\n" + contentStyle.Render("No orders yet") + return contentStyle.Render("No orders yet") } if m.account.orderViewState == 2 { return m.buildOrderDetailView(orders[m.account.orderCursor], width) } - boxWidth := width - 2 + // Whole-card windowing: render only fully-visible cards, never clipping a + // card mid-border. Both scroll arrows render in BuildAccountView's gap + // rows, so the cards span exactly the menu panel's rows. + page := m.orderListPageSize() + start := m.orderHiddenAbove() + end := start + page + if end > len(orders) { + end = len(orders) + } - // Build cards and join vertically — no manual "\n" gaps means fixed spacing - var cards []string - for i, order := range orders { + var rows []string + for i := start; i < end; i++ { isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor - cards = append(cards, m.buildOrderCard(order, boxWidth, isSelected)) + rows = append(rows, m.buildOrderCard(orders[i], width, isSelected)) } - cardList := lipgloss.JoinVertical(lipgloss.Left, cards...) - - // In preview mode (viewState 0), show title + hint - if m.account.orderViewState == 0 { - hintStyle := m.theme.TextDim() - return titleStyle.Render(title) + "\n" + - cardList + "\n" + - hintStyle.Render("enter: browse orders") - } - - // In list browsing mode (viewState 1), just show cards — no title - // (the left panel menu already shows "order history" as selected) - return cardList + return lipgloss.JoinVertical(lipgloss.Left, rows...) } // buildOrderCard renders a single order as a bordered box with fixed height. diff --git a/pkg/tui/cart.go b/pkg/tui/cart.go index 018fab8..15627d1 100644 --- a/pkg/tui/cart.go +++ b/pkg/tui/cart.go @@ -9,36 +9,42 @@ import ( ) func (m Model) updateCartViewport() Model { + contentW, _, _, _ := m.shopLayout() headerH := lipgloss.Height(m.BuildHeader()) breadH := lipgloss.Height(m.BuildBreadcrumbs()) footerH := lipgloss.Height(m.BuildFooter()) - availH := m.heightContainer - headerH - footerH - breadH + // 2 = blank gap rows around the cart list + availH := m.heightContainer - headerH - footerH - breadH - 2 if availH < 1 { availH = 1 } if !m.cart.viewportReady { - m.cart.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH)) + m.cart.viewport = viewport.New(viewport.WithWidth(contentW), viewport.WithHeight(availH)) m.cart.viewport.KeyMap = viewport.KeyMap{} m.cart.viewportReady = true } else { - m.cart.viewport.SetWidth(m.widthContent) + m.cart.viewport.SetWidth(contentW) m.cart.viewport.SetHeight(availH) } return m } func (m Model) generateCartContent() string { + contentW, _, _, _ := m.shopLayout() + if m.IsCartEmpty() { emptyStyle := m.theme.TextDim(). Align(lipgloss.Center). - Width(m.widthContent). + Width(contentW). Padding(2, 0) render := emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop") return render } cartItems := "" - boxWidth := m.widthContent - 4 + // Cart cards span the full chrome width so they line up with the + // header and footer boxes. + boxWidth := contentW itemSlice := m.GetCartItemsSlice() boxPadding := 0 @@ -94,7 +100,7 @@ func (m Model) generateCartContent() string { boxContent = m.theme.TextAccent().Render(boxContent) } box := m.createBoxInner(boxContent, isSelected, lipgloss.Left, 2, boxPadding, boxWidth) - cartItems += m.theme.Base().Width(m.widthContent).Align(lipgloss.Center).Render(box) + cartItems += box if idx < len(itemSlice)-1 { cartItems += "\n" @@ -123,12 +129,9 @@ func (m Model) CartView() string { m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight) } } - return lipgloss.Place( - m.widthContainer, - lipgloss.Height(m.cart.viewport.View()), - lipgloss.Center, lipgloss.Center, - m.cart.viewport.View(), - ) + // Blank rows separate the list from the chrome; no Place wrapper so the + // cards stay left-aligned with the header and footer boxes. + return "\n" + m.cart.viewport.View() + "\n" } func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) { diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 2cd61e1..986e385 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -182,6 +182,7 @@ type accountState struct { cursor int orderViewState int orderCursor int + orderWindowStart int // first fully-visible card in the windowed order list orderYOffset int // saved scroll pos when entering order detail faqFocused bool addressListFocused bool @@ -261,13 +262,12 @@ func (m Model) CartSwitch() (Model, tea.Cmd) { {key: "q", value: "quit"}, } } else { + // Keep this list short enough to fit on one footer line. m.footer = []footerCommand{ {key: "j/k", value: "items"}, {key: "+/-", value: "qty"}, - {key: "p/enter", value: "checkout"}, + {key: "enter", value: "checkout"}, {key: "s", value: "shop"}, - {key: "a", value: "account"}, - {key: "pgup/pgdn", value: "scroll"}, {key: "?", value: "help"}, {key: "q", value: "quit"}, } diff --git a/pkg/tui/render_smoke_test.go b/pkg/tui/render_smoke_test.go index 74823be..092b6d8 100644 --- a/pkg/tui/render_smoke_test.go +++ b/pkg/tui/render_smoke_test.go @@ -99,6 +99,89 @@ func TestRenderAccountSmoke(t *testing.T) { } } +// TestRenderOrderWindowing verifies the windowed order list: whole cards +// only, with ↑/↓ arrow rows marking hidden items. +func TestRenderOrderWindowing(t *testing.T) { + m := NewModel("smoke") + m.Loading = false + m.OrdersLoaded = true + for i := 1; i <= 9; i++ { + m.Orders = append(m.Orders, models.Order{ + ID: uint(9000 + i), + Status: models.OrderStatusPaid, + Total: 1000 + i*100, + }) + } + m = m.updateLayout(100, 40) + m, _ = m.AccountSwitch() + + // Preview mode: window starts at the top, arrow only below. + plain := ansiRE.ReplaceAllString(m.renderString(), "") + t.Log("\n" + plain) + if !strings.Contains(plain, "↓ 4 more") { + t.Errorf("preview mode: expected '↓ 4 more' indicator") + } + if strings.Contains(plain, "↑") { + t.Errorf("preview mode: unexpected up arrow") + } + + // Browse mode scrolled to the middle: arrows on both ends. + m.account.orderViewState = 1 + m.account.orderCursor = 6 + m = m.scrollToAccountDetailItem() + plain = ansiRE.ReplaceAllString(m.renderString(), "") + t.Log("\n" + plain) + if !strings.Contains(plain, "↑ 2 more") || !strings.Contains(plain, "↓ 2 more") { + t.Errorf("browse mode: expected '↑ 2 more' and '↓ 2 more' indicators") + } +} + +// TestRenderCartSmoke verifies the cart page cards line up with the chrome. +func TestRenderCartSmoke(t *testing.T) { + m := NewModel("smoke") + m.Loading = false + // Seed coffees carry no IDs, so assign them for distinct cart keys + for i := range m.Coffees { + m.Coffees[i].ID = uint(i + 1) + } + m.Cart[m.Coffees[0].ID] = &models.CartItem{CoffeeID: m.Coffees[0].ID, Coffee: m.Coffees[0], Quantity: 2} + m.Cart[m.Coffees[1].ID] = &models.CartItem{CoffeeID: m.Coffees[1].ID, Coffee: m.Coffees[1], Quantity: 1} + m = m.updateLayout(100, 40) + m, _ = m.CartSwitch() + + 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") + } + // Every box row (header, cards, footer) must share the chrome edges. + for i := range lefts { + if lefts[i] != lefts[0] || rights[i] != rights[0] { + t.Errorf("box edge misaligned: row %d spans %d-%d, expected %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}} {