From 155d8c4c1d968619a5a0e2b4abb189ad841914ec Mon Sep 17 00:00:00 2001 From: ysandler Date: Mon, 2 Feb 2026 20:32:19 -0600 Subject: [PATCH] feat: add search with preview --- VERSION | 2 +- internal/app/app.go | 22 ++ internal/app/keys.go | 12 +- internal/ui/legend/legend.go | 1 + internal/ui/search/search.go | 532 +++++++++++++++++++++++++++ internal/ui/styles.go | 9 + internal/ui/transcript/transcript.go | 8 + 7 files changed, 584 insertions(+), 2 deletions(-) create mode 100644 internal/ui/search/search.go diff --git a/VERSION b/VERSION index d917d3e..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.2 +0.2.0 diff --git a/internal/app/app.go b/internal/app/app.go index ee0139f..06777a9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -14,6 +14,7 @@ import ( "playback/internal/ui" "playback/internal/ui/header" "playback/internal/ui/legend" + "playback/internal/ui/search" "playback/internal/ui/transcript" "playback/internal/ui/waveform" ) @@ -37,6 +38,7 @@ type Model struct { legend legend.Model waveform waveform.Model transcript transcript.Model + search search.Model // State showHelp bool @@ -61,6 +63,7 @@ func New(audioPath, transcriptPath string) Model { legend: legend.New(), waveform: waveform.New(), transcript: transcript.New(), + search: search.New(), } return m @@ -138,6 +141,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateLayout() case tea.KeyMsg: + // Search overlay takes priority + if m.search.IsOpen() { + cmd := m.search.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + } + // All shortcuts are now global switch { case key.Matches(msg, m.keys.Quit): @@ -168,6 +178,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.SeekBackwardBig): m.player.SeekRelative(-m.config.BigSeekStep) + case key.Matches(msg, m.keys.Search): + cmd := m.search.Open(m.transcript.AllCues()) + cmds = append(cmds, cmd) + // Transcript navigation and other keys forward to transcript default: cmd := m.transcript.Update(msg) @@ -211,6 +225,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case transcript.SeekToCueMsg: m.player.Seek(msg.Position) + + case search.SeekToSearchResultMsg: + m.player.Seek(msg.Position) } return m, tea.Batch(cmds...) @@ -247,6 +264,7 @@ func (m *Model) updateLayout() { m.legend.SetWidth(m.width) m.waveform.SetSize(m.width, waveformHeight) m.transcript.SetSize(m.width, transcriptHeight) + m.search.SetSize(m.width, m.height) } func (m Model) launchEditor() tea.Cmd { @@ -285,6 +303,10 @@ func (m Model) View() string { return m.renderHelp() } + if m.search.IsOpen() { + return m.search.View() + } + // Header headerView := m.header.View(m.player.Position(), m.player.Duration(), m.player.IsPlaying()) diff --git a/internal/app/keys.go b/internal/app/keys.go index 9f7eb0a..25ab20e 100644 --- a/internal/app/keys.go +++ b/internal/app/keys.go @@ -23,6 +23,9 @@ type KeyMap struct { // Editing EnterEdit key.Binding + + // Search + Search key.Binding } // DefaultKeyMap returns the default keybindings @@ -84,6 +87,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("i"), key.WithHelp("i", "edit transcript"), ), + Search: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "search cues"), + ), } } @@ -109,5 +116,8 @@ Navigation (Global): G Go to last cue Editing: - i Edit in $EDITOR at cue` + i Edit in $EDITOR at cue + +Navigation: + s Search cues` } diff --git a/internal/ui/legend/legend.go b/internal/ui/legend/legend.go index 2d60a65..f138edc 100644 --- a/internal/ui/legend/legend.go +++ b/internal/ui/legend/legend.go @@ -57,6 +57,7 @@ func (m Model) View() string { {key: "j/↓", desc: "next cue"}, {key: "k/↑", desc: "prev cue"}, {key: "ctrl+d/u", desc: "jump 5 cues"}, + {key: "s", desc: "search"}, }, }, { diff --git a/internal/ui/search/search.go b/internal/ui/search/search.go new file mode 100644 index 0000000..b65b1bf --- /dev/null +++ b/internal/ui/search/search.go @@ -0,0 +1,532 @@ +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 +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 9be498a..56b82fe 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -105,4 +105,13 @@ var ( ErrorStyle = lipgloss.NewStyle(). Foreground(ColorError). Bold(true) + + // Search overlay styles + SearchMatchStyle = lipgloss.NewStyle(). + Background(ColorAccent). + Foreground(ColorBackground). + Bold(true) + + SearchSelectedStyle = lipgloss.NewStyle(). + Background(ColorHighlight) ) diff --git a/internal/ui/transcript/transcript.go b/internal/ui/transcript/transcript.go index f12d824..743077f 100644 --- a/internal/ui/transcript/transcript.go +++ b/internal/ui/transcript/transcript.go @@ -209,6 +209,14 @@ func (m *Model) scrollToCue(cueIndex int) { m.viewport.SetYOffset(offset) } +// AllCues returns all cues from the transcript +func (m *Model) AllCues() []srt.Cue { + if m.transcript == nil { + return nil + } + return m.transcript.Cues +} + // View renders the transcript func (m Model) View() string { content := m.viewport.View()