diff options
Diffstat (limited to 'internal/tui/wizard.go')
| -rw-r--r-- | internal/tui/wizard.go | 478 |
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 +} |
