bumped bubbletea, lipgloss, bubbles, wish, and huh to v2. Mostly mechanical chur, new import paths, vewport getter/setter api, view() returns tea.view now, huh.theme became an interface so the theme package needed a small reshape. Render plumbing is gone (no more lipgloss.Render field on the Model or MakeRenderer in main.go). All pages still work over ssh. setting up for the refund composer next
431 lines
14 KiB
Go
431 lines
14 KiB
Go
package tui
|
|
|
|
import (
|
|
"charm.land/bubbles/v2/viewport"
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
|
|
"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)
|
|
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
|
|
}
|
|
if availableHeight < 3 {
|
|
availableHeight = 3
|
|
}
|
|
|
|
if !m.account.viewportReady {
|
|
m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight))
|
|
m.account.detailViewport.KeyMap = modifiedKeyMap
|
|
m.account.viewportReady = true
|
|
} else {
|
|
m.account.detailViewport.SetWidth(detailWidth)
|
|
m.account.detailViewport.SetHeight(availableHeight)
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
|
|
return ""
|
|
}
|
|
|
|
selectedItem := models.AccountMenuItems[m.account.cursor]
|
|
switch selectedItem {
|
|
case "active orders":
|
|
return m.ActiveOrdersView(detailContentWidth)
|
|
case "order history":
|
|
return m.OrdersView(detailContentWidth)
|
|
case "addresses":
|
|
return m.AddressesView(detailContentWidth)
|
|
case "cards":
|
|
return m.CardsView(detailContentWidth)
|
|
case "faq":
|
|
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
|
questionStyle := m.theme.TextHighlight().Bold(true)
|
|
contentStyle := m.theme.TextBody().Width(detailContentWidth)
|
|
faqContent := ""
|
|
for i, faq := range m.FAQs {
|
|
faqContent += questionStyle.Render(wordWrap(faq.Question, detailContentWidth)) + "\n"
|
|
faqContent += contentStyle.Render(wordWrap(faq.Answer, detailContentWidth))
|
|
if i < len(m.FAQs)-1 {
|
|
faqContent += "\n\n"
|
|
}
|
|
}
|
|
detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent
|
|
if !m.account.faqFocused {
|
|
hintStyle := m.theme.TextDim()
|
|
detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
|
|
}
|
|
return detailView
|
|
case "about":
|
|
return m.AboutView(detailContentWidth)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// scrollToAccountDetailItem adjusts the viewport scroll to keep the selected item visible.
|
|
// Follows the reference pattern: cursor moves immediately, viewport catches up.
|
|
//
|
|
// Each sub-page has a title header (e.g. "Order History" + margin + newlines) that
|
|
// pushes the first item down. headerOffset accounts for those lines. itemHeight
|
|
// must match the actual rendered card height including borders, padding, and the
|
|
// inter-card gap ("\n" between cards in the view function).
|
|
func (m Model) scrollToAccountDetailItem() Model {
|
|
var itemHeight, itemCount, selectedIndex, headerOffset int
|
|
|
|
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
|
|
case m.account.addressListFocused:
|
|
headerOffset = 3
|
|
itemHeight = 5
|
|
itemCount = len(m.SavedAddresses)
|
|
selectedIndex = m.account.addressCursor
|
|
case m.account.cardListFocused:
|
|
headerOffset = 3
|
|
itemHeight = 4
|
|
itemCount = len(m.SavedCards)
|
|
selectedIndex = m.account.cardCursor
|
|
default:
|
|
return m
|
|
}
|
|
|
|
if itemCount == 0 {
|
|
return m
|
|
}
|
|
|
|
targetY := headerOffset + (selectedIndex * itemHeight)
|
|
vpH := m.account.detailViewport.Height()
|
|
offset := m.account.detailViewport.YOffset()
|
|
|
|
// 1-item buffer: scroll when the NEXT item would be off-screen,
|
|
// not when the current item hits the very edge
|
|
if targetY-itemHeight < offset {
|
|
m.account.detailViewport.SetYOffset(targetY - itemHeight)
|
|
}
|
|
if targetY+itemHeight+itemHeight > offset+vpH {
|
|
m.account.detailViewport.SetYOffset(targetY - vpH + itemHeight + itemHeight)
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
func (m Model) BuildAccountView(availableHeight int) string {
|
|
leftWidth := 18
|
|
|
|
// Build the left panel (menu items)
|
|
leftPanel := ""
|
|
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"
|
|
} else {
|
|
style := m.theme.TextBody().Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
|
|
leftPanel += style.Render(item) + "\n"
|
|
}
|
|
}
|
|
|
|
leftContainer := m.theme.Base().
|
|
Width(leftWidth)
|
|
|
|
// Generate detail content and set it on the viewport
|
|
detailContent := m.getAccountDetailContent()
|
|
m.account.detailViewport.SetContent(detailContent)
|
|
|
|
// 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
|
|
if m.size < large {
|
|
return lipgloss.JoinVertical(
|
|
lipgloss.Left,
|
|
leftContainer.Render(leftPanel),
|
|
detailRendered,
|
|
)
|
|
}
|
|
|
|
return lipgloss.JoinHorizontal(
|
|
lipgloss.Top,
|
|
leftContainer.Render(leftPanel),
|
|
" ",
|
|
detailRendered,
|
|
)
|
|
}
|
|
|
|
func (m Model) AboutView(width int) string {
|
|
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
|
contentStyle := m.theme.TextBody().Width(width)
|
|
// USE this if wanting to add a corp name later
|
|
// accentStyle := m.theme.TextHighlight().Bold(true)
|
|
userStyle := m.theme.TextAccent().Bold(true)
|
|
|
|
aboutContent := contentStyle.Render(wordWrap(
|
|
"This project is heavily inspired by the wonderful team who made TerminalDotShop. I want to thank them for open-sourcing the code for that project. Cudos to those smart, funny, amazing looking devs:",
|
|
width,
|
|
)) + "\n\n"
|
|
aboutContent += userStyle.Render("@thdxr") + "\n"
|
|
aboutContent += userStyle.Render("@adamdotdev") + "\n"
|
|
aboutContent += userStyle.Render("@theprimeagen") + "\n"
|
|
aboutContent += userStyle.Render("@teej_dv") + "\n"
|
|
aboutContent += userStyle.Render("@iamdavidhill") + "\n\n"
|
|
aboutContent += contentStyle.Render("And me:") + "\n\n"
|
|
aboutContent += userStyle.Render("@marcusDenslow") + "\n\n"
|
|
aboutContent += contentStyle.Render(wordWrap(
|
|
"This project tries to copy the idea of terminalDotShop, but with one twist: write everything in GO. this is my humble attempt",
|
|
width,
|
|
)) + "\n"
|
|
|
|
return titleStyle.Render("About") + "\n\n" + aboutContent
|
|
}
|
|
|
|
func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|
keyMsg, ok := msg.(tea.KeyMsg)
|
|
if !ok {
|
|
// Pass non-key messages to viewport (e.g. window resize)
|
|
var cmd tea.Cmd
|
|
m.account.detailViewport, cmd = m.account.detailViewport.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
selectedItem := ""
|
|
if m.account.cursor < len(models.AccountMenuItems) {
|
|
selectedItem = models.AccountMenuItems[m.account.cursor]
|
|
}
|
|
|
|
switch keyMsg.String() {
|
|
case "up", "k":
|
|
// In order detail or FAQ, scroll the viewport directly
|
|
if m.account.orderViewState == 2 || m.account.faqFocused {
|
|
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
|
m.account.detailViewport.ScrollUp(3)
|
|
return m, nil
|
|
}
|
|
if m.account.addressListFocused && m.account.addressDeleting == nil {
|
|
if m.account.addressCursor > 0 {
|
|
m.account.addressCursor--
|
|
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
|
m = m.scrollToAccountDetailItem()
|
|
}
|
|
} else if m.account.cardListFocused && m.account.cardDeleting == nil {
|
|
if m.account.cardCursor > 0 {
|
|
m.account.cardCursor--
|
|
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
|
m = m.scrollToAccountDetailItem()
|
|
}
|
|
} else if m.account.orderViewState == 1 && m.account.orderCursor > 0 {
|
|
m.account.orderCursor--
|
|
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
|
m = m.scrollToAccountDetailItem()
|
|
} else if m.account.orderViewState == 0 && m.account.cursor > 0 {
|
|
m.account.cursor--
|
|
m.account.detailViewport.GotoTop()
|
|
}
|
|
|
|
case "down", "j":
|
|
if m.account.orderViewState == 2 || m.account.faqFocused {
|
|
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
|
m.account.detailViewport.ScrollDown(3)
|
|
return m, nil
|
|
}
|
|
if m.account.addressListFocused && m.account.addressDeleting == nil {
|
|
if m.account.addressCursor < len(m.SavedAddresses)-1 {
|
|
m.account.addressCursor++
|
|
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
|
m = m.scrollToAccountDetailItem()
|
|
}
|
|
} else if m.account.cardListFocused && m.account.cardDeleting == nil {
|
|
if m.account.cardCursor < len(m.SavedCards)-1 {
|
|
m.account.cardCursor++
|
|
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
|
m = m.scrollToAccountDetailItem()
|
|
}
|
|
} else if m.account.orderViewState == 1 && m.account.orderCursor < len(m.currentOrderList())-1 {
|
|
m.account.orderCursor++
|
|
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
|
m = m.scrollToAccountDetailItem()
|
|
} else if m.account.orderViewState == 0 && !m.account.addressListFocused && !m.account.cardListFocused && m.account.cursor < len(models.AccountMenuItems)-1 {
|
|
m.account.cursor++
|
|
m.account.detailViewport.GotoTop()
|
|
}
|
|
|
|
case "pgup", "ctrl+u", "pgdown", "ctrl+d":
|
|
// Ensure viewport has content before delegating scroll
|
|
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
|
var cmd tea.Cmd
|
|
m.account.detailViewport, cmd = m.account.detailViewport.Update(msg)
|
|
return m, cmd
|
|
|
|
case "x", "d":
|
|
if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) {
|
|
m.account.addressDeleting = &m.account.addressCursor
|
|
} else if m.account.cardListFocused && m.account.cardDeleting == nil && m.account.cardCursor < len(m.SavedCards) {
|
|
m.account.cardDeleting = &m.account.cardCursor
|
|
}
|
|
|
|
case "y":
|
|
if m.account.addressDeleting != nil {
|
|
cursor := *m.account.addressDeleting
|
|
addr := m.SavedAddresses[cursor]
|
|
m.account.addressDeleting = nil
|
|
m.SavedAddresses = append(m.SavedAddresses[:cursor], m.SavedAddresses[cursor+1:]...)
|
|
if m.account.addressCursor >= len(m.SavedAddresses) && m.account.addressCursor > 0 {
|
|
m.account.addressCursor--
|
|
}
|
|
return m, m.deleteAddressCmd(addr.ID)
|
|
} else if m.account.cardDeleting != nil {
|
|
cursor := *m.account.cardDeleting
|
|
card := m.SavedCards[cursor]
|
|
m.account.cardDeleting = nil
|
|
m.SavedCards = append(m.SavedCards[:cursor], m.SavedCards[cursor+1:]...)
|
|
if m.account.cardCursor >= len(m.SavedCards) && m.account.cardCursor > 0 {
|
|
m.account.cardCursor--
|
|
}
|
|
return m, m.deleteCardCmd(card.ID)
|
|
}
|
|
|
|
case "n":
|
|
m.account.addressDeleting = nil
|
|
m.account.cardDeleting = nil
|
|
|
|
case "p", "enter":
|
|
switch selectedItem {
|
|
case "order history", "active orders":
|
|
if len(m.currentOrderList()) > 0 {
|
|
switch m.account.orderViewState {
|
|
case 0:
|
|
m.account.orderViewState = 1
|
|
m.account.orderCursor = 0
|
|
m.account.detailViewport.GotoTop()
|
|
m.footer = []footerCommand{
|
|
{key: "j/k", value: "orders"},
|
|
{key: "enter", value: "details"},
|
|
{key: "esc", value: "back"},
|
|
{key: "q", value: "quit"},
|
|
}
|
|
case 1:
|
|
m.account.orderYOffset = m.account.detailViewport.YOffset()
|
|
m.account.orderViewState = 2
|
|
m.account.detailViewport.GotoTop()
|
|
m.footer = []footerCommand{
|
|
{key: "esc", value: "back"},
|
|
{key: "s", value: "shop"},
|
|
{key: "q", value: "quit"},
|
|
}
|
|
}
|
|
}
|
|
case "addresses":
|
|
if !m.account.addressListFocused {
|
|
m.account.addressListFocused = true
|
|
m.account.addressCursor = 0
|
|
m.footer = []footerCommand{
|
|
{key: "j/k", value: "addresses"},
|
|
{key: "x", value: "delete"},
|
|
{key: "esc", value: "back"},
|
|
}
|
|
}
|
|
case "cards":
|
|
if !m.account.cardListFocused {
|
|
m.account.cardListFocused = true
|
|
m.account.cardCursor = 0
|
|
m.footer = []footerCommand{
|
|
{key: "j/k", value: "cards"},
|
|
{key: "x", value: "delete"},
|
|
{key: "esc", value: "back"},
|
|
}
|
|
}
|
|
case "faq":
|
|
if !m.account.faqFocused {
|
|
m.account.faqFocused = true
|
|
m.account.detailViewport.GotoTop()
|
|
m.footer = []footerCommand{
|
|
{key: "j/k", value: "scroll"},
|
|
{key: "esc", value: "back"},
|
|
{key: "s", value: "shop"},
|
|
{key: "c", value: "cart"},
|
|
{key: "q", value: "quit"},
|
|
}
|
|
}
|
|
}
|
|
|
|
case "esc":
|
|
accountDefaultFooter := []footerCommand{
|
|
{key: "j/k", value: "navigate"},
|
|
{key: "enter", value: "select"},
|
|
{key: "s", value: "shop"},
|
|
{key: "c", value: "cart"},
|
|
{key: "?", value: "help"},
|
|
{key: "q", value: "quit"},
|
|
}
|
|
if m.account.addressDeleting != nil {
|
|
m.account.addressDeleting = nil
|
|
} else if m.account.addressListFocused {
|
|
m.account.addressListFocused = false
|
|
m.account.addressCursor = 0
|
|
m.footer = accountDefaultFooter
|
|
} else if m.account.cardDeleting != nil {
|
|
m.account.cardDeleting = nil
|
|
} else if m.account.cardListFocused {
|
|
m.account.cardListFocused = false
|
|
m.account.cardCursor = 0
|
|
m.footer = accountDefaultFooter
|
|
} else if m.account.faqFocused {
|
|
m.account.faqFocused = false
|
|
m.account.detailViewport.GotoTop()
|
|
m.footer = accountDefaultFooter
|
|
} else if m.account.orderViewState > 0 {
|
|
m.account.orderViewState--
|
|
if m.account.orderViewState == 1 {
|
|
// Set content first so SetYOffset can clamp against correct line count
|
|
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
|
if m.account.orderYOffset > 0 {
|
|
m.account.detailViewport.SetYOffset(m.account.orderYOffset)
|
|
m.account.orderYOffset = 0
|
|
}
|
|
m.footer = []footerCommand{
|
|
{key: "j/k", value: "orders"},
|
|
{key: "enter", value: "details"},
|
|
{key: "esc", value: "back"},
|
|
{key: "q", value: "quit"},
|
|
}
|
|
} else {
|
|
m.account.detailViewport.GotoTop()
|
|
m.footer = accountDefaultFooter
|
|
}
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|