package tui import ( "fmt" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/yeho/doks/internal/search" ) type Model struct { results []search.Result cursor int selected *search.Result query string quitting bool width int height int } func New(results []search.Result, query string) Model { return Model{ results: results, cursor: 0, query: query, } } func (m Model) Init() tea.Cmd { return nil } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q", "esc": m.quitting = true return m, tea.Quit case "enter": if len(m.results) > 0 && m.cursor < len(m.results) { m.selected = &m.results[m.cursor] } return m, tea.Quit case "up", "k": if m.cursor > 0 { m.cursor-- } case "down", "j": if m.cursor < len(m.results)-1 { m.cursor++ } case "home", "g": m.cursor = 0 case "end", "G": m.cursor = len(m.results) - 1 } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height } return m, nil } func (m Model) View() string { if len(m.results) == 0 { return NoResultsStyle.Render(fmt.Sprintf("No results found for '%s'", m.query)) } var b strings.Builder // Title title := fmt.Sprintf("Search results for '%s' (%d found)", m.query, len(m.results)) b.WriteString(TitleStyle.Render(title)) b.WriteString("\n\n") // Results visibleResults := m.results maxVisible := 10 if len(visibleResults) > maxVisible { // Show results around cursor start := m.cursor - maxVisible/2 if start < 0 { start = 0 } end := start + maxVisible if end > len(visibleResults) { end = len(visibleResults) start = end - maxVisible if start < 0 { start = 0 } } visibleResults = m.results[start:end] } for i, result := range m.results { // Check if this result is visible visible := false for _, vr := range visibleResults { if vr.Key == result.Key && vr.LineNumber == result.LineNumber { visible = true break } } if !visible { continue } isSelected := i == m.cursor // Cursor indicator cursor := " " if isSelected { cursor = CursorStyle.Render("> ") } // Key - bold and bright (even brighter when selected) var key string if isSelected { key = KeyStyleSelected.Render(result.Key) } else { key = KeyStyle.Render(result.Key) } // Line number - dimmer var lineNum string if isSelected { lineNum = LineNumStyleSelected.Render(fmt.Sprintf(":%d", result.LineNumber)) } else { lineNum = LineNumStyle.Render(fmt.Sprintf(":%d", result.LineNumber)) } // Truncate line text if too long lineText := result.LineText maxLineLen := 50 if len(lineText) > maxLineLen { lineText = lineText[:maxLineLen-3] + "..." } // Match text - dimmer than key var matchText string if isSelected { matchText = MatchTextStyleSelected.Render(lineText) } else { matchText = MatchTextStyle.Render(lineText) } line := fmt.Sprintf("%s%s%s %s", cursor, key, lineNum, matchText) b.WriteString(line) b.WriteString("\n") } // Help help := "↑/k up • ↓/j down • enter select • q/esc quit" b.WriteString(HelpStyle.Render(help)) return b.String() } func (m Model) Selected() *search.Result { return m.selected } func (m Model) Quitting() bool { return m.quitting } func Run(results []search.Result, query string) (*search.Result, error) { model := New(results, query) p := tea.NewProgram(model) finalModel, err := p.Run() if err != nil { return nil, err } m := finalModel.(Model) return m.Selected(), nil }