summaryrefslogtreecommitdiffstats
path: root/internal/tui/wizard.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui/wizard.go')
-rw-r--r--internal/tui/wizard.go478
1 files changed, 478 insertions, 0 deletions
diff --git a/internal/tui/wizard.go b/internal/tui/wizard.go
new file mode 100644
index 0000000..34ba70e
--- /dev/null
+++ b/internal/tui/wizard.go
@@ -0,0 +1,478 @@
+package tui
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "git.db.org.ai/dborg/internal/formatter"
+ "github.com/charmbracelet/bubbles/table"
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+type wizardState int
+
+const (
+ stateForm wizardState = iota
+ stateSearching
+ stateResults
+ stateGenerating
+ stateDone
+ stateError
+)
+
+type SearchFunc func(firstName, lastName, city, state, age string) (map[string]interface{}, string, error)
+type ReportFunc func(sxKey string, selection int) (map[string]interface{}, error)
+
+type WizardModel struct {
+ state wizardState
+ inputs []textinput.Model
+ focusIndex int
+ table table.Model
+ people []Person
+ selectedIdx int
+ sxKey string
+ reportData map[string]interface{}
+ err error
+ searchFn SearchFunc
+ reportFn ReportFunc
+ windowWidth int
+ windowHeight int
+ jsonOutput bool
+}
+
+var (
+ focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
+ blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
+ cursorStyle = focusedStyle
+ noStyle = lipgloss.NewStyle()
+
+ focusedButton = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("229")).
+ Background(lipgloss.Color("57")).
+ Padding(0, 2)
+
+ blurredButton = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("243")).
+ Padding(0, 2)
+
+ wizardTitleStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lipgloss.Color("86")).
+ MarginBottom(1)
+
+ tableStyle = lipgloss.NewStyle().
+ BorderStyle(lipgloss.NormalBorder()).
+ BorderForeground(lipgloss.Color("240"))
+
+ statusStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("241")).
+ Italic(true)
+)
+
+func NewWizardModel(searchFn SearchFunc, reportFn ReportFunc, jsonOutput bool) WizardModel {
+ inputs := make([]textinput.Model, 5)
+
+ inputs[0] = textinput.New()
+ inputs[0].Placeholder = "John"
+ inputs[0].Focus()
+ inputs[0].CharLimit = 50
+ inputs[0].Width = 30
+ inputs[0].Prompt = "First Name: "
+ inputs[0].PromptStyle = focusedStyle
+ inputs[0].TextStyle = focusedStyle
+
+ inputs[1] = textinput.New()
+ inputs[1].Placeholder = "Doe"
+ inputs[1].CharLimit = 50
+ inputs[1].Width = 30
+ inputs[1].Prompt = "Last Name: "
+ inputs[1].PromptStyle = blurredStyle
+ inputs[1].TextStyle = blurredStyle
+
+ inputs[2] = textinput.New()
+ inputs[2].Placeholder = "New York"
+ inputs[2].CharLimit = 50
+ inputs[2].Width = 30
+ inputs[2].Prompt = "City: "
+ inputs[2].PromptStyle = blurredStyle
+ inputs[2].TextStyle = blurredStyle
+
+ inputs[3] = textinput.New()
+ inputs[3].Placeholder = "NY"
+ inputs[3].CharLimit = 2
+ inputs[3].Width = 10
+ inputs[3].Prompt = "State: "
+ inputs[3].PromptStyle = blurredStyle
+ inputs[3].TextStyle = blurredStyle
+
+ inputs[4] = textinput.New()
+ inputs[4].Placeholder = "35"
+ inputs[4].CharLimit = 3
+ inputs[4].Width = 10
+ inputs[4].Prompt = "Age: "
+ inputs[4].PromptStyle = blurredStyle
+ inputs[4].TextStyle = blurredStyle
+
+ return WizardModel{
+ state: stateForm,
+ inputs: inputs,
+ focusIndex: 0,
+ searchFn: searchFn,
+ reportFn: reportFn,
+ jsonOutput: jsonOutput,
+ }
+}
+
+type searchResultMsg struct {
+ data map[string]interface{}
+ sxKey string
+ err error
+}
+
+type reportResultMsg struct {
+ data map[string]interface{}
+ err error
+}
+
+func (m WizardModel) Init() tea.Cmd {
+ return textinput.Blink
+}
+
+func (m WizardModel) doSearch() tea.Cmd {
+ return func() tea.Msg {
+ data, sxKey, err := m.searchFn(
+ m.inputs[0].Value(),
+ m.inputs[1].Value(),
+ m.inputs[2].Value(),
+ strings.ToUpper(m.inputs[3].Value()),
+ m.inputs[4].Value(),
+ )
+ if err != nil {
+ return searchResultMsg{err: err}
+ }
+ return searchResultMsg{data: data, sxKey: sxKey}
+ }
+}
+
+func (m WizardModel) doReport() tea.Cmd {
+ return func() tea.Msg {
+ data, err := m.reportFn(m.sxKey, m.selectedIdx+1)
+ if err != nil {
+ return reportResultMsg{err: err}
+ }
+ return reportResultMsg{data: data}
+ }
+}
+
+func (m WizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.windowWidth = msg.Width
+ m.windowHeight = msg.Height
+
+ case tea.KeyMsg:
+ switch m.state {
+ case stateForm:
+ return m.updateForm(msg)
+ case stateResults:
+ return m.updateResults(msg)
+ case stateError:
+ if msg.String() == "q" || msg.String() == "ctrl+c" || msg.String() == "enter" {
+ return m, tea.Quit
+ }
+ case stateDone:
+ return m, tea.Quit
+ }
+
+ case searchResultMsg:
+ if msg.err != nil {
+ m.state = stateError
+ m.err = msg.err
+ return m, nil
+ }
+
+ people, err := extractPeople(msg.data)
+ if err != nil {
+ m.state = stateError
+ m.err = err
+ return m, nil
+ }
+
+ if len(people) == 0 {
+ m.state = stateError
+ m.err = fmt.Errorf("no results found")
+ return m, nil
+ }
+
+ m.people = people
+ m.sxKey = msg.sxKey
+ m.table = m.buildTable()
+ m.state = stateResults
+ return m, nil
+
+ case reportResultMsg:
+ if msg.err != nil {
+ m.state = stateError
+ m.err = msg.err
+ return m, nil
+ }
+
+ m.reportData = msg.data
+ m.state = stateDone
+ return m, tea.Quit
+ }
+
+ return m, nil
+}
+
+func (m WizardModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "ctrl+c", "esc":
+ return m, tea.Quit
+
+ case "tab", "down":
+ m.focusIndex++
+ if m.focusIndex > len(m.inputs) {
+ m.focusIndex = 0
+ }
+ return m.updateFocus(), nil
+
+ case "shift+tab", "up":
+ m.focusIndex--
+ if m.focusIndex < 0 {
+ m.focusIndex = len(m.inputs)
+ }
+ return m.updateFocus(), nil
+
+ case "enter":
+ if m.focusIndex == len(m.inputs) {
+ if m.inputs[0].Value() == "" || m.inputs[1].Value() == "" {
+ m.err = fmt.Errorf("first name and last name are required")
+ return m, nil
+ }
+ m.state = stateSearching
+ m.err = nil
+ return m, m.doSearch()
+ }
+ m.focusIndex++
+ if m.focusIndex > len(m.inputs) {
+ m.focusIndex = len(m.inputs)
+ }
+ return m.updateFocus(), nil
+ }
+
+ cmd := m.updateInputs(msg)
+ return m, cmd
+}
+
+func (m WizardModel) updateResults(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "ctrl+c", "q":
+ return m, tea.Quit
+
+ case "esc":
+ m.state = stateForm
+ m.focusIndex = 0
+ return m.updateFocus(), nil
+
+ case "enter":
+ m.selectedIdx = m.table.Cursor()
+ m.state = stateGenerating
+ return m, m.doReport()
+ }
+
+ var cmd tea.Cmd
+ m.table, cmd = m.table.Update(msg)
+ return m, cmd
+}
+
+func (m *WizardModel) updateFocus() WizardModel {
+ for i := range m.inputs {
+ if i == m.focusIndex {
+ m.inputs[i].Focus()
+ m.inputs[i].PromptStyle = focusedStyle
+ m.inputs[i].TextStyle = focusedStyle
+ } else {
+ m.inputs[i].Blur()
+ m.inputs[i].PromptStyle = blurredStyle
+ m.inputs[i].TextStyle = blurredStyle
+ }
+ }
+ return *m
+}
+
+func (m *WizardModel) updateInputs(msg tea.KeyMsg) tea.Cmd {
+ cmds := make([]tea.Cmd, len(m.inputs))
+ for i := range m.inputs {
+ m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
+ }
+ return tea.Batch(cmds...)
+}
+
+func (m WizardModel) buildTable() table.Model {
+ columns := []table.Column{
+ {Title: "#", Width: 4},
+ {Title: "Name", Width: 25},
+ {Title: "Age", Width: 6},
+ {Title: "Location", Width: 25},
+ {Title: "Phone Hint", Width: 15},
+ }
+
+ rows := make([]table.Row, len(m.people))
+ for i, p := range m.people {
+ rows[i] = table.Row{
+ fmt.Sprintf("%d", p.Index),
+ p.Name,
+ p.Age,
+ p.Location,
+ p.PhoneHint,
+ }
+ }
+
+ t := table.New(
+ table.WithColumns(columns),
+ table.WithRows(rows),
+ table.WithFocused(true),
+ table.WithHeight(min(len(rows)+1, 15)),
+ )
+
+ s := table.DefaultStyles()
+ s.Header = s.Header.
+ BorderStyle(lipgloss.NormalBorder()).
+ BorderForeground(lipgloss.Color("240")).
+ BorderBottom(true).
+ Bold(false)
+ s.Selected = s.Selected.
+ Foreground(lipgloss.Color("229")).
+ Background(lipgloss.Color("57")).
+ Bold(false)
+ t.SetStyles(s)
+
+ return t
+}
+
+func (m WizardModel) View() string {
+ switch m.state {
+ case stateForm:
+ return m.viewForm()
+ case stateSearching:
+ return m.viewSearching()
+ case stateResults:
+ return m.viewResults()
+ case stateGenerating:
+ return m.viewGenerating()
+ case stateError:
+ return m.viewError()
+ case stateDone:
+ return ""
+ }
+ return ""
+}
+
+func (m WizardModel) viewForm() string {
+ var b strings.Builder
+
+ b.WriteString(wizardTitleStyle.Render("Skiptrace Person Search"))
+ b.WriteString("\n\n")
+
+ for i := range m.inputs {
+ b.WriteString(m.inputs[i].View())
+ b.WriteString("\n")
+ }
+
+ b.WriteString("\n")
+
+ button := blurredButton.Render("[ Search ]")
+ if m.focusIndex == len(m.inputs) {
+ button = focusedButton.Render("[ Search ]")
+ }
+ b.WriteString(button)
+
+ if m.err != nil {
+ b.WriteString("\n\n")
+ b.WriteString(errorStyle.Render(m.err.Error()))
+ }
+
+ b.WriteString("\n\n")
+ b.WriteString(helpStyle.Render("tab/shift+tab: navigate | enter: submit | esc: quit"))
+
+ return b.String()
+}
+
+func (m WizardModel) viewSearching() string {
+ return wizardTitleStyle.Render("Skiptrace Person Search") + "\n\n" +
+ statusStyle.Render("Searching...")
+}
+
+func (m WizardModel) viewResults() string {
+ var b strings.Builder
+
+ b.WriteString(wizardTitleStyle.Render("Select a Person"))
+ b.WriteString("\n")
+ b.WriteString(statusStyle.Render(fmt.Sprintf("Found %d results", len(m.people))))
+ b.WriteString("\n\n")
+
+ b.WriteString(tableStyle.Render(m.table.View()))
+
+ b.WriteString("\n\n")
+ b.WriteString(helpStyle.Render("j/k or arrows: navigate | enter: select | esc: back | q: quit"))
+
+ return b.String()
+}
+
+func (m WizardModel) viewGenerating() string {
+ return wizardTitleStyle.Render("Generating Report") + "\n\n" +
+ statusStyle.Render("Please wait, this may take a moment...")
+}
+
+func (m WizardModel) viewError() string {
+ return wizardTitleStyle.Render("Error") + "\n\n" +
+ errorStyle.Render(m.err.Error()) + "\n\n" +
+ helpStyle.Render("press enter or q to exit")
+}
+
+func (m WizardModel) GetReportData() map[string]interface{} {
+ return m.reportData
+}
+
+func RunSkiptraceWizard(searchFn SearchFunc, reportFn ReportFunc, jsonOutput bool) error {
+ tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
+ if err != nil {
+ return fmt.Errorf("failed to open terminal: %w", err)
+ }
+ defer tty.Close()
+
+ model := NewWizardModel(searchFn, reportFn, jsonOutput)
+ p := tea.NewProgram(model, tea.WithInput(tty), tea.WithOutput(tty))
+
+ finalModel, err := p.Run()
+ if err != nil {
+ return fmt.Errorf("wizard error: %w", err)
+ }
+
+ m, ok := finalModel.(WizardModel)
+ if !ok {
+ return fmt.Errorf("unexpected model type")
+ }
+
+ if m.state == stateError {
+ return m.err
+ }
+
+ if m.reportData != nil {
+ if jsonOutput {
+ output, err := FormatReportJSON(m.reportData)
+ if err != nil {
+ return err
+ }
+ fmt.Println(output)
+ } else {
+ fmt.Println(formatter.FormatSkiptraceReport(m.reportData))
+ }
+ }
+
+ return nil
+}