533 lines
12 KiB
Go
533 lines
12 KiB
Go
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
|
|
}
|