diff --git a/cmd/tui.go b/cmd/tui.go index d762a4c..1b629bc 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -1,13 +1,12 @@ package cmd import ( - "bufio" + "context" "fmt" "io" "os" - "strconv" - "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" "github.com/tayloree/publix-deals/internal/api" "github.com/tayloree/publix-deals/internal/display" @@ -15,11 +14,9 @@ import ( "golang.org/x/term" ) -const tuiPageSize = 10 - var tuiCmd = &cobra.Command{ Use: "tui", - Short: "Browse deals interactively in the terminal", + Short: "Browse deals in a full-screen interactive terminal UI", Example: ` pubcli tui --zip 33101 pubcli tui --store 1425 --category produce --sort ending`, RunE: runTUI, @@ -34,50 +31,111 @@ func runTUI(cmd *cobra.Command, _ []string) error { if err := validateSortMode(); err != nil { return err } - if !flagJSON && !isInteractiveSession(cmd.InOrStdin(), cmd.OutOrStdout()) { - return invalidArgsError( - "`pubcli tui` requires an interactive terminal", - "Use `pubcli --zip 33101 --json` in pipelines.", - ) - } - client := api.NewClient() - storeNumber, err := resolveStore(cmd, client) - if err != nil { - return err - } - - resp, err := client.FetchSavings(cmd.Context(), storeNumber) - if err != nil { - return upstreamError("fetching deals", err) - } - if len(resp.Savings) == 0 { - return notFoundError( - fmt.Sprintf("no deals found for store #%s", storeNumber), - "Try another store with --store.", - ) - } - - items := filter.Apply(resp.Savings, filter.Options{ + initialOpts := filter.Options{ BOGO: flagBogo, Category: flagCategory, Department: flagDepartment, Query: flagQuery, Sort: flagSort, Limit: flagLimit, - }) - if len(items) == 0 { - return notFoundError( - "no deals match your filters", - "Relax filters like --category/--department/--query.", - ) } if flagJSON { + _, _, rawItems, err := loadTUIData(cmd.Context(), flagStore, flagZip) + if err != nil { + return err + } + items := filter.Apply(rawItems, initialOpts) + if len(items) == 0 { + return notFoundError( + "no deals match your filters", + "Relax filters like --category/--department/--query.", + ) + } return display.PrintDealsJSON(cmd.OutOrStdout(), items) } - return runTUILoop(cmd.OutOrStdout(), cmd.InOrStdin(), storeNumber, items) + if !isInteractiveSession(cmd.InOrStdin(), cmd.OutOrStdout()) { + return invalidArgsError( + "`pubcli tui` requires an interactive terminal", + "Use `pubcli --zip 33101 --json` in pipelines.", + ) + } + + model := newLoadingDealsTUIModel(tuiLoadConfig{ + ctx: cmd.Context(), + storeNumber: flagStore, + zipCode: flagZip, + initialOpts: initialOpts, + }) + + program := tea.NewProgram( + model, + tea.WithAltScreen(), + tea.WithInput(cmd.InOrStdin()), + tea.WithOutput(cmd.OutOrStdout()), + ) + + finalModel, err := program.Run() + if err != nil { + return fmt.Errorf("running tui: %w", err) + } + if finalState, ok := finalModel.(dealsTUIModel); ok && finalState.fatalErr != nil { + return finalState.fatalErr + } + return nil +} + +func resolveStoreForTUI(ctx context.Context, client *api.Client, storeNumber, zipCode string) (resolvedStoreNumber, storeLabel string, err error) { + if storeNumber != "" { + return storeNumber, "#" + storeNumber, nil + } + if zipCode == "" { + return "", "", invalidArgsError( + "please provide --store NUMBER or --zip ZIPCODE", + "pubcli tui --zip 33101", + "pubcli tui --store 1425", + ) + } + + stores, err := client.FetchStores(ctx, zipCode, 1) + if err != nil { + return "", "", upstreamError("finding stores", err) + } + if len(stores) == 0 { + return "", "", notFoundError( + fmt.Sprintf("no Publix stores found near %s", zipCode), + "Try a nearby ZIP code.", + ) + } + + store := stores[0] + resolvedStoreNumber = api.StoreNumber(store.Key) + storeLabel = fmt.Sprintf("#%s — %s (%s, %s)", resolvedStoreNumber, store.Name, store.City, store.State) + return resolvedStoreNumber, storeLabel, nil +} + +func loadTUIData(ctx context.Context, storeNumber, zipCode string) (resolvedStoreNumber, storeLabel string, items []api.SavingItem, err error) { + client := api.NewClient() + + resolvedStoreNumber, storeLabel, err = resolveStoreForTUI(ctx, client, storeNumber, zipCode) + if err != nil { + return "", "", nil, err + } + + resp, err := client.FetchSavings(ctx, resolvedStoreNumber) + if err != nil { + return "", "", nil, upstreamError("fetching deals", err) + } + if len(resp.Savings) == 0 { + return "", "", nil, notFoundError( + fmt.Sprintf("no deals found for store #%s", resolvedStoreNumber), + "Try another store with --store.", + ) + } + + return resolvedStoreNumber, storeLabel, resp.Savings, nil } func isInteractiveSession(stdin io.Reader, stdout io.Writer) bool { @@ -90,66 +148,3 @@ func isInteractiveSession(stdin io.Reader, stdout io.Writer) bool { } return isTTY(stdout) } - -func runTUILoop(out io.Writer, in io.Reader, storeNumber string, items []api.SavingItem) error { - reader := bufio.NewReader(in) - page := 0 - totalPages := (len(items)-1)/tuiPageSize + 1 - - for { - renderTUIPage(out, storeNumber, items, page, totalPages) - - line, err := reader.ReadString('\n') - if err != nil { - return nil - } - cmd := strings.TrimSpace(strings.ToLower(line)) - - switch cmd { - case "q", "quit", "exit": - return nil - case "n", "next": - if page < totalPages-1 { - page++ - } - case "p", "prev", "previous": - if page > 0 { - page-- - } - default: - idx, convErr := strconv.Atoi(cmd) - if convErr != nil || idx < 1 || idx > len(items) { - continue - } - renderDealDetail(out, items[idx-1], idx, len(items)) - if _, err := reader.ReadString('\n'); err != nil { - return nil - } - } - } -} - -func renderTUIPage(out io.Writer, storeNumber string, items []api.SavingItem, page, totalPages int) { - fmt.Fprint(out, "\033[H\033[2J") - fmt.Fprintf(out, "pubcli tui | store #%s | %d deals | page %d/%d\n\n", storeNumber, len(items), page+1, totalPages) - - start := page * tuiPageSize - end := minInt(start+tuiPageSize, len(items)) - for i := start; i < end; i++ { - item := items[i] - title := topDealTitle(item) - savings := filter.CleanText(filter.Deref(item.Savings)) - if savings == "" { - savings = "-" - } - fmt.Fprintf(out, "%2d. %s [%s]\n", i+1, title, savings) - } - fmt.Fprintf(out, "\ncommands: number=details | n=next | p=prev | q=quit\n> ") -} - -func renderDealDetail(out io.Writer, item api.SavingItem, index, total int) { - fmt.Fprint(out, "\033[H\033[2J") - fmt.Fprintf(out, "deal %d/%d\n\n", index, total) - display.PrintDeals(out, []api.SavingItem{item}) - fmt.Fprint(out, "press Enter to return\n") -} diff --git a/cmd/tui_model.go b/cmd/tui_model.go new file mode 100644 index 0000000..db321cf --- /dev/null +++ b/cmd/tui_model.go @@ -0,0 +1,1128 @@ +package cmd + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/tayloree/publix-deals/internal/api" + "github.com/tayloree/publix-deals/internal/filter" +) + +const ( + minTUIWidth = 92 + minTUIHeight = 24 +) + +var ( + tuiHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")) + tuiMetaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + tuiHintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + tuiValueStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")) + tuiBogoStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205")) + tuiDealStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")) + tuiMutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + tuiSectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("81")) +) + +type tuiLoadConfig struct { + ctx context.Context + storeNumber string + zipCode string + initialOpts filter.Options +} + +type tuiDataLoadedMsg struct { + storeLabel string + allDeals []api.SavingItem + initialOpts filter.Options +} + +type tuiDataLoadErrMsg struct { + err error +} + +type tuiFocus int + +const ( + tuiFocusList tuiFocus = iota + tuiFocusDetail +) + +type tuiGroupItem struct { + name string + count int + ordinal int +} + +func (g tuiGroupItem) FilterValue() string { return strings.ToLower(g.name) } +func (g tuiGroupItem) Title() string { return fmt.Sprintf("%d. %s", g.ordinal, g.name) } +func (g tuiGroupItem) Description() string { + return fmt.Sprintf("Section header • %d deals", g.count) +} + +type tuiDealItem struct { + deal api.SavingItem + group string + title string + description string + filterValue string +} + +func (d tuiDealItem) FilterValue() string { return d.filterValue } +func (d tuiDealItem) Title() string { return d.title } +func (d tuiDealItem) Description() string { return d.description } + +type dealsTUIModel struct { + loading bool + spinner spinner.Model + loadCmd tea.Cmd + fatalErr error + + storeLabel string + allDeals []api.SavingItem + + opts filter.Options + initialOpts filter.Options + + sortChoices []string + sortIndex int + categoryChoices []string + categoryIndex int + departmentChoices []string + departmentIndex int + limitChoices []int + limitIndex int + + list list.Model + detail viewport.Model + + focus tuiFocus + showHelp bool + selectedID string + + groupStarts []int + visibleDeals int + + width, height int + bodyHeight int + listPaneWidth int + detailPaneWidth int + tooSmall bool +} + +func newLoadingDealsTUIModel(cfg tuiLoadConfig) dealsTUIModel { + delegate := list.NewDefaultDelegate() + delegate.SetHeight(2) + delegate.SetSpacing(1) + + lst := list.New([]list.Item{}, delegate, 0, 0) + lst.Title = "Deals" + lst.SetStatusBarItemName("item", "items") + lst.SetShowStatusBar(true) + lst.SetFilteringEnabled(true) + lst.SetShowHelp(false) + lst.SetShowPagination(true) + lst.DisableQuitKeybindings() + + detail := viewport.New(0, 0) + detail.KeyMap.PageDown.SetKeys("f", "pgdown") + detail.KeyMap.PageUp.SetKeys("b", "pgup") + detail.KeyMap.HalfPageDown.SetKeys("d") + detail.KeyMap.HalfPageUp.SetKeys("u") + + spin := spinner.New() + spin.Spinner = spinner.Dot + spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("86")) + + return dealsTUIModel{ + loading: true, + spinner: spin, + loadCmd: loadTUIDataCmd(cfg), + initialOpts: cfg.initialOpts, + opts: cfg.initialOpts, + list: lst, + detail: detail, + focus: tuiFocusList, + } +} + +func loadTUIDataCmd(cfg tuiLoadConfig) tea.Cmd { + return func() tea.Msg { + _, storeLabel, allDeals, err := loadTUIData(cfg.ctx, cfg.storeNumber, cfg.zipCode) + if err != nil { + return tuiDataLoadErrMsg{err: err} + } + return tuiDataLoadedMsg{ + storeLabel: storeLabel, + allDeals: allDeals, + initialOpts: cfg.initialOpts, + } + } +} + +func (m dealsTUIModel) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, m.loadCmd) +} + +func (m dealsTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.resize() + return m, nil + + case tuiDataLoadedMsg: + m.loading = false + m.storeLabel = msg.storeLabel + m.allDeals = msg.allDeals + m.initialOpts = canonicalizeTUIOptions(msg.initialOpts) + m.opts = m.initialOpts + m.initializeInlineChoices() + m.applyCurrentFilters(true) + m.resize() + return m, nil + + case tuiDataLoadErrMsg: + m.loading = false + m.fatalErr = msg.err + return m, tea.Quit + + case spinner.TickMsg: + if m.loading { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + } + + keyMsg, isKey := msg.(tea.KeyMsg) + if isKey { + if keyMsg.String() == "ctrl+c" { + return m, tea.Quit + } + if m.loading { + if keyMsg.String() == "q" { + return m, tea.Quit + } + return m, nil + } + } + + if m.loading { + return m, nil + } + + if isKey { + filtering := m.list.FilterState() == list.Filtering + key := keyMsg.String() + + switch key { + case "q": + if !filtering { + return m, tea.Quit + } + case "tab": + if !filtering { + if m.focus == tuiFocusList { + m.focus = tuiFocusDetail + } else { + m.focus = tuiFocusList + } + return m, nil + } + case "esc": + if m.focus == tuiFocusDetail && !filtering { + m.focus = tuiFocusList + return m, nil + } + case "?": + if !filtering { + m.showHelp = !m.showHelp + m.resize() + return m, nil + } + case "s": + if !filtering { + m.cycleSortMode() + return m, nil + } + case "g": + if !filtering { + m.opts.BOGO = !m.opts.BOGO + m.applyCurrentFilters(false) + return m, nil + } + case "c": + if !filtering { + m.cycleCategory() + return m, nil + } + case "a": + if !filtering { + m.cycleDepartment() + return m, nil + } + case "l": + if !filtering { + m.cycleLimit() + return m, nil + } + case "r": + if !filtering { + m.opts = m.initialOpts + m.syncChoiceIndexesFromOptions() + m.applyCurrentFilters(false) + return m, nil + } + case "]": + if !filtering { + if m.list.IsFiltered() { + return m, m.list.NewStatusMessage("Clear fuzzy filter before section jumps.") + } + m.jumpSection(1) + return m, nil + } + case "[": + if !filtering { + if m.list.IsFiltered() { + return m, m.list.NewStatusMessage("Clear fuzzy filter before section jumps.") + } + m.jumpSection(-1) + return m, nil + } + } + + if !filtering && len(key) == 1 && key[0] >= '1' && key[0] <= '9' { + if m.list.IsFiltered() { + return m, m.list.NewStatusMessage("Clear fuzzy filter before section jumps.") + } + m.jumpToSection(int(key[0] - '1')) + return m, nil + } + + if m.focus == tuiFocusDetail && !filtering { + var cmd tea.Cmd + m.detail, cmd = m.detail.Update(msg) + return m, cmd + } + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + m.refreshDetail(false) + return m, cmd +} + +func (m dealsTUIModel) View() string { + if m.loading { + return m.loadingView() + } + if m.width == 0 || m.height == 0 { + return tuiMetaStyle.Render("Loading interface...") + } + if m.tooSmall { + return lipgloss.NewStyle(). + Padding(1, 2). + Render( + fmt.Sprintf( + "Terminal too small (%dx%d).\nResize to at least %dx%d for the two-pane deal explorer.", + m.width, m.height, minTUIWidth, minTUIHeight, + ), + ) + } + + return lipgloss.JoinVertical( + lipgloss.Left, + m.headerView(), + m.bodyView(), + m.footerView(), + ) +} + +func (m dealsTUIModel) loadingView() string { + width := m.width + if width == 0 { + width = 80 + } + skeletonStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) + + lines := []string{ + tuiHeaderStyle.Render("pubcli tui"), + tuiMetaStyle.Render("Preparing interactive interface..."), + "", + fmt.Sprintf("%s Fetching store and weekly deals", m.spinner.View()), + tuiHintStyle.Render("Tip: press q to cancel."), + "", + skeletonStyle.Render("┌──────────────────────────────┬─────────────────────────────────────────┐"), + skeletonStyle.Render("│ Loading deal list... │ Loading detail panel... │"), + skeletonStyle.Render("│ • categories │ • pricing and validity metadata │"), + skeletonStyle.Render("│ • sections │ • wrapped description text │"), + skeletonStyle.Render("│ • filter index │ • scroll viewport │"), + skeletonStyle.Render("└──────────────────────────────┴─────────────────────────────────────────┘"), + } + + return lipgloss.NewStyle(). + Width(width). + Padding(1, 2). + Render(strings.Join(lines, "\n")) +} + +func (m *dealsTUIModel) resize() { + if m.width == 0 || m.height == 0 { + return + } + if m.loading { + return + } + + m.tooSmall = m.width < minTUIWidth || m.height < minTUIHeight + if m.tooSmall { + return + } + + headerH := 3 + footerH := 2 + if m.showHelp { + footerH = 7 + } + m.bodyHeight = maxInt(8, m.height-headerH-footerH-1) + + listWidth := maxInt(40, int(float64(m.width)*0.43)) + if listWidth > m.width-42 { + listWidth = m.width / 2 + } + detailWidth := m.width - listWidth - 1 + if detailWidth < 36 { + detailWidth = 36 + listWidth = m.width - detailWidth - 1 + } + + m.listPaneWidth = listWidth + m.detailPaneWidth = detailWidth + + listInnerWidth := maxInt(24, listWidth-4) + detailInnerWidth := maxInt(24, detailWidth-4) + panelInnerHeight := maxInt(6, m.bodyHeight-2) + + m.list.SetSize(listInnerWidth, panelInnerHeight) + m.detail.Width = detailInnerWidth + m.detail.Height = panelInnerHeight + m.refreshDetail(false) +} + +func (m dealsTUIModel) headerView() string { + focus := "list" + if m.focus == tuiFocusDetail { + focus = "detail" + } + + top := fmt.Sprintf("pubcli tui | %s", m.storeLabel) + bottom := fmt.Sprintf( + "deals: %d visible / %d total | filters: %s | focus: %s", + m.visibleDeals, len(m.allDeals), m.activeFilterSummary(), focus, + ) + + return lipgloss.NewStyle(). + Width(m.width). + Padding(0, 1). + Render(tuiHeaderStyle.Render(top) + "\n" + tuiMetaStyle.Render(bottom)) +} + +func (m dealsTUIModel) bodyView() string { + listBorder := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("241")). + Padding(0, 1) + detailBorder := listBorder + + if m.focus == tuiFocusList { + listBorder = listBorder.BorderForeground(lipgloss.Color("86")) + } else { + detailBorder = detailBorder.BorderForeground(lipgloss.Color("86")) + } + + left := listBorder. + Width(m.listPaneWidth). + Height(m.bodyHeight). + Render(m.list.View()) + right := detailBorder. + Width(m.detailPaneWidth). + Height(m.bodyHeight). + Render(m.detail.View()) + + return lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) +} + +func (m dealsTUIModel) footerView() string { + base := "Tab switch pane • / fuzzy filter • s sort • g bogo • c category • a department • l limit • r reset • [/] section jump • 1-9 section index • q quit" + if m.focus == tuiFocusDetail { + base = "Detail: j/k or ↑/↓ scroll • u/d half-page • b/f page • esc list • ? help • q quit" + } + + if !m.showHelp { + return lipgloss.NewStyle().Padding(0, 1).Render(tuiHintStyle.Render(base)) + } + + lines := []string{ + "Key Help", + "list pane: ↑/↓ or j/k move • / fuzzy filter • c category • a department • g bogo • s sort • l limit", + "group jumps: ] next section • [ previous section • 1..9 jump to numbered section header", + "detail pane: j/k or ↑/↓ scroll • u/d half-page • b/f page up/down", + "global: tab switch pane • esc list • r reset inline options • ? toggle help • q quit • ctrl+c force quit", + } + return lipgloss.NewStyle(). + Padding(0, 1). + Render(tuiHintStyle.Render(strings.Join(lines, "\n"))) +} + +func (m *dealsTUIModel) initializeInlineChoices() { + m.opts = canonicalizeTUIOptions(m.opts) + + m.sortChoices = []string{"", "savings", "ending"} + m.categoryChoices = buildCategoryChoices(m.allDeals, m.opts.Category) + m.departmentChoices = buildDepartmentChoices(m.allDeals, m.opts.Department) + m.limitChoices = buildLimitChoices(m.opts.Limit) + + m.syncChoiceIndexesFromOptions() +} + +func (m *dealsTUIModel) syncChoiceIndexesFromOptions() { + m.sortIndex = indexOfString(m.sortChoices, canonicalSortMode(m.opts.Sort)) + if m.sortIndex < 0 { + m.sortIndex = 0 + } + m.opts.Sort = m.sortChoices[m.sortIndex] + + m.categoryIndex = indexOfStringFold(m.categoryChoices, m.opts.Category) + if m.categoryIndex < 0 { + m.categoryIndex = 0 + m.opts.Category = "" + } else { + m.opts.Category = m.categoryChoices[m.categoryIndex] + } + + m.departmentIndex = indexOfStringFold(m.departmentChoices, m.opts.Department) + if m.departmentIndex < 0 { + m.departmentIndex = 0 + m.opts.Department = "" + } else { + m.opts.Department = m.departmentChoices[m.departmentIndex] + } + + m.limitIndex = indexOfInt(m.limitChoices, m.opts.Limit) + if m.limitIndex < 0 { + m.limitIndex = 0 + m.opts.Limit = m.limitChoices[m.limitIndex] + } +} + +func (m *dealsTUIModel) cycleSortMode() { + if len(m.sortChoices) == 0 { + return + } + m.sortIndex = (m.sortIndex + 1) % len(m.sortChoices) + m.opts.Sort = m.sortChoices[m.sortIndex] + m.applyCurrentFilters(false) +} + +func (m *dealsTUIModel) cycleCategory() { + if len(m.categoryChoices) == 0 { + return + } + m.categoryIndex = (m.categoryIndex + 1) % len(m.categoryChoices) + m.opts.Category = m.categoryChoices[m.categoryIndex] + m.applyCurrentFilters(false) +} + +func (m *dealsTUIModel) cycleDepartment() { + if len(m.departmentChoices) == 0 { + return + } + m.departmentIndex = (m.departmentIndex + 1) % len(m.departmentChoices) + m.opts.Department = m.departmentChoices[m.departmentIndex] + m.applyCurrentFilters(false) +} + +func (m *dealsTUIModel) cycleLimit() { + if len(m.limitChoices) == 0 { + return + } + m.limitIndex = (m.limitIndex + 1) % len(m.limitChoices) + m.opts.Limit = m.limitChoices[m.limitIndex] + m.applyCurrentFilters(false) +} + +func (m dealsTUIModel) activeFilterSummary() string { + parts := []string{} + if m.opts.BOGO { + parts = append(parts, "bogo") + } + if m.opts.Category != "" { + parts = append(parts, "category:"+m.opts.Category) + } + if m.opts.Department != "" { + parts = append(parts, "department:"+m.opts.Department) + } + if m.opts.Query != "" { + parts = append(parts, "query:"+m.opts.Query) + } + if m.opts.Sort != "" { + parts = append(parts, "sort:"+m.opts.Sort) + } + if m.opts.Limit > 0 { + parts = append(parts, fmt.Sprintf("limit:%d", m.opts.Limit)) + } + if fuzzy := strings.TrimSpace(m.list.FilterValue()); fuzzy != "" { + parts = append(parts, "fuzzy:"+fuzzy) + } + if len(parts) == 0 { + return "none" + } + return strings.Join(parts, ", ") +} + +func (m *dealsTUIModel) applyCurrentFilters(resetSelection bool) { + currentID := m.selectedID + filtered := filter.Apply(m.allDeals, m.opts) + m.visibleDeals = len(filtered) + + items, starts := buildGroupedListItems(filtered) + m.groupStarts = starts + + m.list.Title = fmt.Sprintf("Deals • %d visible", m.visibleDeals) + m.list.SetItems(items) + + target := -1 + if !resetSelection && currentID != "" { + target = findItemIndexByID(items, currentID) + } + if target < 0 { + target = firstDealItemIndex(items) + } + if target < 0 && len(items) > 0 { + target = 0 + } + if target >= 0 { + m.list.Select(target) + } + + m.refreshDetail(true) +} + +func (m *dealsTUIModel) refreshDetail(resetScroll bool) { + var content string + nextID := "" + + if selected := m.list.SelectedItem(); selected != nil { + switch item := selected.(type) { + case tuiDealItem: + content = renderDealDetailContent(item.deal, m.detail.Width) + nextID = stableIDForDeal(item.deal, item.title) + case tuiGroupItem: + content = m.renderGroupDetail(item) + nextID = stableIDForGroup(item.name) + } + } + if content == "" { + content = "No deals match the current inline filters.\n\nTry pressing r to reset filters." + } + + if resetScroll || nextID != m.selectedID { + m.detail.GotoTop() + } + m.selectedID = nextID + m.detail.SetContent(content) +} + +func (m dealsTUIModel) renderGroupDetail(group tuiGroupItem) string { + preview := m.groupPreviewTitles(group.name, 5) + + lines := []string{ + tuiSectionStyle.Render(fmt.Sprintf("Section %d: %s", group.ordinal, group.name)), + tuiMetaStyle.Render(fmt.Sprintf("%d deals in this section", group.count)), + "", + tuiMetaStyle.Render("Jump keys:"), + "- `]` next section, `[` previous section", + "- `1..9` jump directly to section number", + } + if len(preview) > 0 { + lines = append(lines, "") + lines = append(lines, tuiMetaStyle.Render("Preview:")) + for _, title := range preview { + lines = append(lines, "• "+title) + } + } + + return strings.Join(lines, "\n") +} + +func (m dealsTUIModel) groupPreviewTitles(group string, max int) []string { + out := make([]string, 0, max) + for _, item := range m.list.Items() { + deal, ok := item.(tuiDealItem) + if !ok || deal.group != group { + continue + } + out = append(out, deal.title) + if len(out) >= max { + break + } + } + return out +} + +func (m *dealsTUIModel) jumpToSection(index int) { + if index < 0 || index >= len(m.groupStarts) { + return + } + + target := firstDealIndexFrom(m.list.Items(), m.groupStarts[index]) + if target < 0 { + target = m.groupStarts[index] + } + m.list.Select(target) + m.refreshDetail(true) +} + +func (m *dealsTUIModel) jumpSection(delta int) { + if len(m.groupStarts) == 0 { + return + } + + current := m.currentSectionIndex() + if current < 0 { + current = 0 + } + next := current + delta + if next < 0 { + next = len(m.groupStarts) - 1 + } + if next >= len(m.groupStarts) { + next = 0 + } + m.jumpToSection(next) +} + +func (m dealsTUIModel) currentSectionIndex() int { + if len(m.groupStarts) == 0 { + return -1 + } + cursor := m.list.GlobalIndex() + current := 0 + for i, start := range m.groupStarts { + if start <= cursor { + current = i + continue + } + break + } + return current +} + +func buildGroupedListItems(deals []api.SavingItem) (items []list.Item, starts []int) { + if len(deals) == 0 { + return nil, nil + } + + groups := map[string][]api.SavingItem{} + for _, deal := range deals { + group := dealGroupLabel(deal) + groups[group] = append(groups[group], deal) + } + + type groupMeta struct { + name string + count int + } + + metas := make([]groupMeta, 0, len(groups)) + for name, deals := range groups { + metas = append(metas, groupMeta{name: name, count: len(deals)}) + } + sort.Slice(metas, func(i, j int) bool { + if metas[i].name == "BOGO" && metas[j].name != "BOGO" { + return true + } + if metas[j].name == "BOGO" && metas[i].name != "BOGO" { + return false + } + if metas[i].count != metas[j].count { + return metas[i].count > metas[j].count + } + return metas[i].name < metas[j].name + }) + + items = make([]list.Item, 0, len(deals)+len(metas)) + starts = make([]int, 0, len(metas)) + for idx, meta := range metas { + starts = append(starts, len(items)) + + items = append(items, tuiGroupItem{ + name: meta.name, + count: meta.count, + ordinal: idx + 1, + }) + for _, deal := range groups[meta.name] { + items = append(items, buildTUIDealItem(deal, meta.name)) + } + } + + return items, starts +} + +func dealGroupLabel(item api.SavingItem) string { + if filter.ContainsIgnoreCase(item.Categories, "bogo") { + return "BOGO" + } + for _, category := range item.Categories { + clean := strings.TrimSpace(category) + if clean == "" || strings.EqualFold(clean, "bogo") { + continue + } + return humanizeLabel(clean) + } + if dept := strings.TrimSpace(filter.CleanText(filter.Deref(item.Department))); dept != "" { + return humanizeLabel(dept) + } + return "Other" +} + +func buildTUIDealItem(item api.SavingItem, group string) tuiDealItem { + title := topDealTitle(item) + savings := filter.CleanText(filter.Deref(item.Savings)) + if savings == "" { + savings = "No savings text" + } + dept := filter.CleanText(filter.Deref(item.Department)) + end := strings.TrimSpace(item.EndFormatted) + + descParts := []string{savings} + if dept != "" { + descParts = append(descParts, dept) + } + if end != "" { + descParts = append(descParts, "ends "+end) + } + + filterTokens := []string{ + title, + savings, + filter.CleanText(filter.Deref(item.Description)), + filter.CleanText(filter.Deref(item.Brand)), + strings.Join(item.Categories, " "), + dept, + end, + group, + } + + return tuiDealItem{ + deal: item, + group: group, + title: title, + description: strings.Join(descParts, " • "), + filterValue: strings.ToLower(strings.Join(filterTokens, " ")), + } +} + +func renderDealDetailContent(item api.SavingItem, width int) string { + maxWidth := maxInt(24, width) + + title := topDealTitle(item) + savings := filter.CleanText(filter.Deref(item.Savings)) + if savings == "" { + savings = "No savings value provided" + } + + desc := filter.CleanText(filter.Deref(item.Description)) + if desc == "" { + desc = "No description provided." + } + + dept := filter.CleanText(filter.Deref(item.Department)) + brand := filter.CleanText(filter.Deref(item.Brand)) + dealInfo := filter.CleanText(filter.Deref(item.AdditionalDealInfo)) + validity := strings.TrimSpace(item.StartFormatted + " - " + item.EndFormatted) + imageURL := strings.TrimSpace(filter.Deref(item.ImageURL)) + + lines := []string{ + tuiDealStyle.Render(wrapText(title, maxWidth)), + } + + metaBits := []string{} + if filter.ContainsIgnoreCase(item.Categories, "bogo") { + metaBits = append(metaBits, tuiBogoStyle.Render("BOGO")) + } + if len(item.Categories) > 0 { + metaBits = append(metaBits, "categories: "+strings.Join(item.Categories, ", ")) + } + if len(metaBits) > 0 { + lines = append(lines, tuiMetaStyle.Render(wrapText(strings.Join(metaBits, " | "), maxWidth))) + } + + lines = append(lines, "") + lines = append(lines, fmt.Sprintf("%s %s", tuiMetaStyle.Render("Savings:"), tuiValueStyle.Render(savings))) + if dealInfo != "" { + lines = append(lines, fmt.Sprintf("%s %s", tuiMetaStyle.Render("Deal info:"), wrapText(dealInfo, maxWidth))) + } + lines = append(lines, "") + lines = append(lines, tuiMetaStyle.Render("Description:")) + lines = append(lines, wrapText(desc, maxWidth)) + lines = append(lines, "") + + if dept != "" { + lines = append(lines, fmt.Sprintf("%s %s", tuiMetaStyle.Render("Department:"), dept)) + } + if brand != "" { + lines = append(lines, fmt.Sprintf("%s %s", tuiMetaStyle.Render("Brand:"), brand)) + } + if strings.Trim(validity, " -") != "" { + lines = append(lines, fmt.Sprintf("%s %s", tuiMetaStyle.Render("Valid:"), strings.Trim(validity, " -"))) + } + lines = append(lines, fmt.Sprintf("%s %.2f", tuiMetaStyle.Render("Score:"), filter.DealScore(item))) + + if imageURL != "" { + lines = append(lines, "") + lines = append(lines, tuiMutedStyle.Render("Image URL:")) + lines = append(lines, tuiMutedStyle.Render(wrapText(imageURL, maxWidth))) + } + + return strings.Join(lines, "\n") +} + +func wrapText(text string, width int) string { + words := strings.Fields(text) + if len(words) == 0 { + return "" + } + if width < 12 { + width = 12 + } + + line := words[0] + lines := make([]string, 0, len(words)/6+1) + for _, w := range words[1:] { + if len(line)+1+len(w) > width { + lines = append(lines, line) + line = w + continue + } + line += " " + w + } + lines = append(lines, line) + return strings.Join(lines, "\n") +} + +func canonicalizeTUIOptions(opts filter.Options) filter.Options { + opts.Sort = canonicalSortMode(opts.Sort) + if opts.Category != "" { + opts.Category = strings.TrimSpace(opts.Category) + } + if opts.Department != "" { + opts.Department = strings.TrimSpace(opts.Department) + } + if opts.Query != "" { + opts.Query = strings.TrimSpace(opts.Query) + } + return opts +} + +func canonicalSortMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "savings": + return "savings" + case "ending", "end", "expiry", "expiration": + return "ending" + default: + return "" + } +} + +func buildCategoryChoices(items []api.SavingItem, current string) []string { + type bucket struct { + label string + count int + } + counts := map[string]bucket{} + for _, item := range items { + for _, category := range item.Categories { + clean := strings.ToLower(strings.TrimSpace(category)) + if clean == "" { + continue + } + entry := counts[clean] + entry.label = clean + entry.count++ + counts[clean] = entry + } + } + + values := make([]string, 0, len(counts)) + for _, value := range counts { + values = append(values, value.label) + } + if current != "" && indexOfStringFold(values, current) < 0 { + values = append(values, current) + } + sort.Strings(values) + sort.SliceStable(values, func(i, j int) bool { + left := counts[strings.ToLower(values[i])].count + right := counts[strings.ToLower(values[j])].count + if left != right { + return left > right + } + return strings.ToLower(values[i]) < strings.ToLower(values[j]) + }) + return append([]string{""}, values...) +} + +func buildDepartmentChoices(items []api.SavingItem, current string) []string { + type bucket struct { + label string + count int + } + counts := map[string]bucket{} + for _, item := range items { + dept := strings.ToLower(strings.TrimSpace(filter.CleanText(filter.Deref(item.Department)))) + if dept == "" { + continue + } + entry := counts[dept] + entry.label = dept + entry.count++ + counts[dept] = entry + } + + values := make([]string, 0, len(counts)) + for _, value := range counts { + values = append(values, value.label) + } + if current != "" && indexOfStringFold(values, current) < 0 { + values = append(values, current) + } + sort.Strings(values) + sort.SliceStable(values, func(i, j int) bool { + left := counts[strings.ToLower(values[i])].count + right := counts[strings.ToLower(values[j])].count + if left != right { + return left > right + } + return strings.ToLower(values[i]) < strings.ToLower(values[j]) + }) + return append([]string{""}, values...) +} + +func buildLimitChoices(current int) []int { + values := []int{0, 10, 25, 50, 100} + if current > 0 && indexOfInt(values, current) < 0 { + values = append(values, current) + sort.Ints(values) + } + return values +} + +func indexOfString(values []string, target string) int { + for i, value := range values { + if value == target { + return i + } + } + return -1 +} + +func indexOfStringFold(values []string, target string) int { + for i, value := range values { + if strings.EqualFold(value, target) { + return i + } + } + return -1 +} + +func indexOfInt(values []int, target int) int { + for i, value := range values { + if value == target { + return i + } + } + return -1 +} + +func findItemIndexByID(items []list.Item, stableID string) int { + for i, item := range items { + if stableIDForItem(item) == stableID { + return i + } + } + return -1 +} + +func firstDealItemIndex(items []list.Item) int { + return firstDealIndexFrom(items, 0) +} + +func firstDealIndexFrom(items []list.Item, start int) int { + for i := start; i < len(items); i++ { + if _, ok := items[i].(tuiDealItem); ok { + return i + } + } + return -1 +} + +func stableIDForItem(item list.Item) string { + switch value := item.(type) { + case tuiDealItem: + return stableIDForDeal(value.deal, value.title) + case tuiGroupItem: + return stableIDForGroup(value.name) + default: + return "" + } +} + +func stableIDForDeal(item api.SavingItem, fallbackTitle string) string { + if id := strings.TrimSpace(item.ID); id != "" { + return "deal:" + id + } + if fallbackTitle != "" { + return "deal:title:" + strings.ToLower(strings.TrimSpace(fallbackTitle)) + } + return "deal:unknown" +} + +func stableIDForGroup(group string) string { + return "group:" + strings.ToLower(strings.TrimSpace(group)) +} + +func humanizeLabel(raw string) string { + s := strings.TrimSpace(raw) + if s == "" { + return "Other" + } + s = strings.ReplaceAll(s, "_", " ") + s = strings.ReplaceAll(s, "-", " ") + words := strings.Fields(strings.ToLower(s)) + for i, word := range words { + if len(word) == 0 { + continue + } + words[i] = strings.ToUpper(word[:1]) + word[1:] + } + return strings.Join(words, " ") +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/cmd/tui_model_test.go b/cmd/tui_model_test.go new file mode 100644 index 0000000..795e2bd --- /dev/null +++ b/cmd/tui_model_test.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/tayloree/publix-deals/internal/api" +) + +func strPtr(value string) *string { return &value } + +func TestCanonicalSortMode(t *testing.T) { + assert.Equal(t, "savings", canonicalSortMode("savings")) + assert.Equal(t, "ending", canonicalSortMode("end")) + assert.Equal(t, "ending", canonicalSortMode("expiry")) + assert.Equal(t, "ending", canonicalSortMode("expiration")) + assert.Equal(t, "", canonicalSortMode("relevance")) + assert.Equal(t, "", canonicalSortMode("unknown")) +} + +func TestBuildGroupedListItems_BogoFirstAndNumberedHeaders(t *testing.T) { + deals := []api.SavingItem{ + {ID: "1", Title: strPtr("Bananas"), Categories: []string{"produce"}}, + {ID: "2", Title: strPtr("Chicken"), Categories: []string{"meat", "bogo"}}, + {ID: "3", Title: strPtr("Apples"), Categories: []string{"produce"}}, + {ID: "4", Title: strPtr("Ground Beef"), Categories: []string{"meat"}}, + } + + items, starts := buildGroupedListItems(deals) + + assert.NotEmpty(t, items) + assert.Equal(t, []int{0, 2, 5}, starts) + + header, ok := items[0].(tuiGroupItem) + assert.True(t, ok) + assert.Equal(t, "BOGO", header.name) + assert.Equal(t, 1, header.ordinal) + + header2, ok := items[2].(tuiGroupItem) + assert.True(t, ok) + assert.Equal(t, "Produce", header2.name) + assert.Equal(t, 2, header2.count) + + header3, ok := items[5].(tuiGroupItem) + assert.True(t, ok) + assert.Equal(t, "Meat", header3.name) + assert.Equal(t, 1, header3.count) +} + +func TestBuildCategoryChoices_AlwaysIncludesCurrent(t *testing.T) { + deals := []api.SavingItem{ + {Categories: []string{"produce"}}, + {Categories: []string{"meat"}}, + } + + choices := buildCategoryChoices(deals, "seafood") + + assert.Contains(t, choices, "") + assert.Contains(t, choices, "produce") + assert.Contains(t, choices, "meat") + assert.Contains(t, choices, "seafood") +} diff --git a/go.mod b/go.mod index 816bb5a..8bb170f 100644 --- a/go.mod +++ b/go.mod @@ -3,28 +3,40 @@ module github.com/tayloree/publix-deals go 1.24.4 require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 github.com/stretchr/testify v1.11.1 golang.org/x/term v0.30.0 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e0e2b3f..43ccef3 100644 --- a/go.sum +++ b/go.sum @@ -1,34 +1,61 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= @@ -38,13 +65,16 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=