package search import ( "fmt" "strings" "time" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "playback/internal/srt" "playback/internal/ui" ) // SeekToSearchResultMsg is sent when user wants to seek to a search result type SeekToSearchResultMsg struct { Position time.Duration } // Model represents the search overlay component type Model struct { input textinput.Model cues []srt.Cue results []int // indices into cues cursor int // selected index into results scrollOffset int width int height int open bool lastQuery string } // New creates a new search model func New() Model { input := textinput.New() input.Prompt = " search: " input.PromptStyle = lipgloss.NewStyle().Foreground(ui.ColorAccent) input.TextStyle = lipgloss.NewStyle().Foreground(ui.ColorForeground) // Disable NextSuggestion and PrevSuggestion so j/k work properly input.KeyMap.NextSuggestion = (textinput.KeyMap{}).NextSuggestion input.KeyMap.PrevSuggestion = (textinput.KeyMap{}).PrevSuggestion return Model{ input: input, } } // Open opens the search overlay with the given cues func (m *Model) Open(cues []srt.Cue) tea.Cmd { m.open = true m.cues = cues m.input.SetValue("") m.cursor = 0 m.scrollOffset = 0 m.lastQuery = "" m.filter() m.input.Focus() return m.input.Focus() } // Close closes the search overlay func (m *Model) Close() { m.open = false m.input.Blur() } // IsOpen returns whether the search overlay is open func (m Model) IsOpen() bool { return m.open } // SetSize sets the component dimensions func (m *Model) SetSize(w, h int) { m.width = w m.height = h } // Update handles messages func (m *Model) Update(msg tea.Msg) tea.Cmd { if !m.open { return nil } switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "esc": m.Close() return nil case "enter": if len(m.results) > 0 { m.Close() return func() tea.Msg { return SeekToSearchResultMsg{ Position: m.cues[m.results[m.cursor]].Start, } } } return nil case "j", "down": if m.cursor < len(m.results)-1 { m.cursor++ m.ensureVisible() } return nil case "k", "up": if m.cursor > 0 { m.cursor-- m.ensureVisible() } return nil default: // Forward other keys to textinput var cmd tea.Cmd m.input, cmd = m.input.Update(msg) // Check if query changed and rebuild results if m.input.Value() != m.lastQuery { m.cursor = 0 m.scrollOffset = 0 m.filter() } return cmd } } return nil } // filter rebuilds the results list based on current query func (m *Model) filter() { m.lastQuery = m.input.Value() m.results = make([]int, 0) query := strings.ToLower(m.lastQuery) if query == "" { // Empty query: show all cues for i := range m.cues { m.results = append(m.results, i) } } else { // Filter by query for i, cue := range m.cues { if strings.Contains(strings.ToLower(cue.Text), query) { m.results = append(m.results, i) } } } // Reset cursor and scroll to start m.cursor = 0 m.scrollOffset = 0 } // ensureVisible adjusts scrollOffset so cursor stays visible func (m *Model) ensureVisible() { bodyHeight := m.bodyHeight() if bodyHeight <= 0 { return } if m.cursor < m.scrollOffset { m.scrollOffset = m.cursor } else if m.cursor >= m.scrollOffset+bodyHeight { m.scrollOffset = m.cursor - bodyHeight + 1 } } // View renders the search overlay func (m Model) View() string { if !m.open { return "" } overlayW := max(60, m.width*9/10) overlayH := max(20, m.height*4/5) innerW := overlayW - 2 - 2 // border + padding innerH := overlayH - 2 - 1 - 1 - 1 // border, input, separator, footer // Build content content := m.buildContent(innerW, innerH) // Apply border and padding style := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(ui.ColorPrimary). Padding(0, 1) bordered := style.Render(content) // Place in center return lipgloss.Place( m.width, m.height, lipgloss.Center, lipgloss.Center, bordered, ) } // buildContent builds the inner content of the overlay func (m *Model) buildContent(innerW, innerH int) string { var lines []string // Input line with background inputLine := m.renderInputLine(innerW) lines = append(lines, inputLine) // Separator separator := strings.Repeat("─", innerW) lines = append(lines, lipgloss.NewStyle().Foreground(ui.ColorMuted).Render(separator)) // Body bodyHeight := innerH - 1 // -1 for separator already counted if bodyHeight > 0 { body := m.renderBody(innerW, bodyHeight) bodyLines := strings.Split(body, "\n") lines = append(lines, bodyLines...) } // Footer footer := m.renderFooter(innerW) lines = append(lines, footer) return strings.Join(lines, "\n") } // renderInputLine renders the search input with background func (m *Model) renderInputLine(width int) string { inputView := m.input.View() // Apply background to the entire input line width style := lipgloss.NewStyle(). Width(width). Background(ui.ColorHighlight) return style.Render(inputView) } // renderBody renders the search results and preview func (m *Model) renderBody(innerW, bodyHeight int) string { resultsW := innerW * 2 / 5 separatorW := 3 previewW := innerW - resultsW - separatorW // Render results column resultsCol := m.renderResultsColumn(resultsW, bodyHeight) // Render separator column separatorLines := make([]string, bodyHeight) for i := range separatorLines { separatorLines[i] = lipgloss.NewStyle().Foreground(ui.ColorMuted).Render("┃") } // Render preview column previewCol := m.renderPreviewColumn(previewW, bodyHeight) // Pad columns to full height and width resultsLines := strings.Split(resultsCol, "\n") previewLines := strings.Split(previewCol, "\n") // Ensure all columns have same number of lines for len(resultsLines) < bodyHeight { resultsLines = append(resultsLines, "") } for len(previewLines) < bodyHeight { previewLines = append(previewLines, "") } // Truncate to bodyHeight if len(resultsLines) > bodyHeight { resultsLines = resultsLines[:bodyHeight] } if len(previewLines) > bodyHeight { previewLines = previewLines[:bodyHeight] } // Ensure each line has proper width for i := range resultsLines { resultsLines[i] = lipgloss.NewStyle().Width(resultsW).Render(resultsLines[i]) } for i := range previewLines { previewLines[i] = lipgloss.NewStyle().Width(previewW).Render(previewLines[i]) } // Build the body by joining lines horizontally var result []string for i := 0; i < bodyHeight; i++ { separatorLine := lipgloss.NewStyle().Foreground(ui.ColorMuted).Render("┃") line := lipgloss.JoinHorizontal( lipgloss.Top, resultsLines[i], " ", separatorLine, " ", previewLines[i], ) result = append(result, line) } return strings.Join(result, "\n") } // renderResultsColumn renders the results list func (m *Model) renderResultsColumn(width, height int) string { var lines []string visibleStart := m.scrollOffset visibleEnd := min(m.scrollOffset+height, len(m.results)) for i := visibleStart; i < visibleEnd; i++ { line := m.renderResultLine(i, width) lines = append(lines, line) } // Pad with empty lines if needed for i := len(lines); i < height; i++ { lines = append(lines, "") } return strings.Join(lines, "\n") } // renderResultLine renders a single result line func (m *Model) renderResultLine(resultIdx int, width int) string { cueIdx := m.results[resultIdx] cue := m.cues[cueIdx] // Prefix prefix := " " if resultIdx == m.cursor { prefix = lipgloss.NewStyle().Foreground(ui.ColorSecondary).Render("> ") } // Timestamp timestamp := formatTime(cue.Start) if resultIdx == m.cursor { timestamp = lipgloss.NewStyle().Foreground(ui.ColorSecondary).Render(timestamp) } else { timestamp = lipgloss.NewStyle().Foreground(ui.ColorMuted).Render(timestamp) } // Cue text with match highlighting text := cue.Text query := strings.ToLower(m.lastQuery) if query != "" { idx := strings.Index(strings.ToLower(text), query) if idx >= 0 { before := text[:idx] match := text[idx : idx+len(query)] after := text[idx+len(query):] text = before + ui.SearchMatchStyle.Render(match) + after } } // Calculate available width for text availW := width - lipgloss.Width(prefix) - lipgloss.Width(timestamp) - 2 if availW < 5 { availW = 5 } // Truncate text if needed if len(text) > availW { text = text[:availW-1] + "…" } line := prefix + timestamp + " " + text // Apply selection background if this is the cursor if resultIdx == m.cursor { line = ui.SearchSelectedStyle.Render(line) } return line } // renderPreviewColumn renders the preview of the selected cue func (m *Model) renderPreviewColumn(width, height int) string { if len(m.results) == 0 { emptyMsg := lipgloss.NewStyle().Foreground(ui.ColorMuted).Render("no matches") return emptyMsg } cueIdx := m.results[m.cursor] cue := m.cues[cueIdx] var lines []string // Timestamp line timestamp := formatTimestampFull(cue.Start, cue.End) lines = append(lines, lipgloss.NewStyle().Foreground(ui.ColorMuted).Render(timestamp)) lines = append(lines, "") // blank line // Cue text (word-wrapped) textLines := wordWrap(cue.Text, width) for _, tline := range textLines { lines = append(lines, lipgloss.NewStyle().Foreground(ui.ColorForeground).Render(tline)) } // Context if cueIdx > 0 { lines = append(lines, "") prevCue := m.cues[cueIdx-1] prevText := truncate(prevCue.Text, width-2) lines = append(lines, lipgloss.NewStyle().Foreground(ui.ColorMuted).Render("↑ "+prevText)) } if cueIdx < len(m.cues)-1 { lines = append(lines, "") nextCue := m.cues[cueIdx+1] nextText := truncate(nextCue.Text, width-2) lines = append(lines, lipgloss.NewStyle().Foreground(ui.ColorMuted).Render("↓ "+nextText)) } // Limit to height if len(lines) > height { lines = lines[:height] } return strings.Join(lines, "\n") } // renderFooter renders the footer func (m *Model) renderFooter(width int) string { var countStr string if len(m.results) == 0 { countStr = "no matches" } else { countStr = fmt.Sprintf("%d result", len(m.results)) if len(m.results) != 1 { countStr += "s" } } mutedStyle := lipgloss.NewStyle().Foreground(ui.ColorMuted) left := mutedStyle.Render(countStr) mid := mutedStyle.Render("↑↓ navigate") right := mutedStyle.Render("esc close") dots := mutedStyle.Render(" · ") return lipgloss.JoinHorizontal( lipgloss.Left, left, dots, mid, dots, right, ) } // bodyHeight returns the available height for the body func (m *Model) bodyHeight() int { overlayH := max(20, m.height*4/5) return overlayH - 2 - 1 - 1 - 1 // border, input, separator, footer } // Helper functions func max(a, b int) int { if a > b { return a } return b } func min(a, b int) int { if a < b { return a } return b } func formatTime(d time.Duration) string { totalSecs := int(d.Seconds()) mins := totalSecs / 60 secs := totalSecs % 60 return fmt.Sprintf("%02d:%02d", mins, secs) } func formatTimestampFull(start, end time.Duration) string { toHMS := func(d time.Duration) string { totalMS := int(d.Milliseconds()) h := totalMS / 3600000 m := (totalMS % 3600000) / 60000 s := (totalMS % 60000) / 1000 ms := totalMS % 1000 return fmt.Sprintf("%02d:%02d:%02d,%03d", h, m, s, ms) } return toHMS(start) + " --> " + toHMS(end) } func truncate(text string, width int) string { if len(text) <= width { return text } return text[:width-1] + "…" } func wordWrap(text string, width int) []string { if width < 5 { width = 5 } words := strings.Fields(text) var lines []string var currentLine string for _, word := range words { if currentLine == "" { currentLine = word } else if len(currentLine)+1+len(word) <= width { currentLine += " " + word } else { lines = append(lines, currentLine) currentLine = word } } if currentLine != "" { lines = append(lines, currentLine) } return lines }