feat: unified navigation

fix: install script copying build binary
chore: updated readme
This commit is contained in:
2026-01-25 21:49:34 -06:00
parent 1bbfc332d8
commit 6165a10481
10 changed files with 273 additions and 209 deletions

View File

@@ -56,21 +56,18 @@ var (
BorderForeground(ColorPrimary).
Padding(0, 1)
CurrentCueStyle = lipgloss.NewStyle().
Background(ColorHighlight).
Foreground(ColorSecondary).
ActiveCueStyle = lipgloss.NewStyle().
Background(ColorSecondary).
Foreground(ColorBackground).
Bold(true)
SelectedCueStyle = lipgloss.NewStyle().
Foreground(ColorAccent).
Bold(true)
ActiveTimestampStyle = lipgloss.NewStyle().
Foreground(ColorBackground).
Bold(true)
TimestampStyle = lipgloss.NewStyle().
Foreground(ColorMuted)
SelectedTimestampStyle = lipgloss.NewStyle().
Foreground(ColorAccent)
// Status bar styles
StatusBarStyle = lipgloss.NewStyle().
Foreground(ColorMuted).

View File

@@ -10,34 +10,28 @@ import (
)
// RenderCue renders a single cue with optional highlighting
func RenderCue(cue *srt.Cue, isCurrent, isSelected bool, width int) string {
func RenderCue(cue *srt.Cue, isActive bool, width int) string {
// Format timestamp
timestamp := formatTimestamp(cue.Start, cue.End)
// Apply styles based on state
var textStyle, timestampStyle lipgloss.Style
if isSelected {
// Selected cue (navigation cursor) - use accent color
textStyle = ui.SelectedCueStyle
timestampStyle = ui.SelectedTimestampStyle
} else if isCurrent {
// Current cue (playback position)
textStyle = ui.CurrentCueStyle
timestampStyle = ui.TimestampStyle
var prefix string
if isActive {
// Active cue (playback position + navigation cursor)
textStyle = ui.ActiveCueStyle
timestampStyle = ui.ActiveTimestampStyle
prefix = "> "
} else {
textStyle = ui.BaseStyle
timestampStyle = ui.TimestampStyle
prefix = " "
}
timestampStr := timestampStyle.Render(timestamp)
textStr := textStyle.Render(cue.Text)
// Add selection indicator
prefix := " "
if isSelected {
prefix = "> "
}
return fmt.Sprintf("%s%s\n%s%s\n", prefix, timestampStr, prefix, textStr)
}

View File

@@ -18,14 +18,12 @@ type SeekToCueMsg struct {
// Model represents the transcript view component
type Model struct {
viewport viewport.Model
transcript *srt.Transcript
currentCue int // Cue currently playing (from playback position)
selectedCue int // Cue selected by user navigation
cueLines []int // Starting line number (in rendered view) for each cue
Width int
Height int
Focused bool
viewport viewport.Model
transcript *srt.Transcript
activeCue int // Cue currently active (playback position + navigation cursor)
cueLines []int // Starting line number (in rendered view) for each cue
Width int
Height int
}
// New creates a new transcript model
@@ -34,16 +32,15 @@ func New() Model {
vp.Style = lipgloss.NewStyle()
return Model{
viewport: vp,
currentCue: -1,
selectedCue: 0,
viewport: vp,
activeCue: -1,
}
}
// SetTranscript sets the transcript to display
func (m *Model) SetTranscript(t *srt.Transcript) {
m.transcript = t
m.selectedCue = 0
m.activeCue = 0
m.updateContent()
m.scrollToCue(0)
}
@@ -53,12 +50,12 @@ func (m *Model) Transcript() *srt.Transcript {
return m.transcript
}
// SelectedCueLineNumber returns the line number of the selected cue for vim
func (m *Model) SelectedCueLineNumber() int {
if m.transcript == nil || m.selectedCue < 0 || m.selectedCue >= len(m.transcript.Cues) {
// ActiveCueLineNumber returns the line number of the active cue for vim
func (m *Model) ActiveCueLineNumber() int {
if m.transcript == nil || m.activeCue < 0 || m.activeCue >= len(m.transcript.Cues) {
return 1
}
return m.transcript.Cues[m.selectedCue].LineNumber
return m.transcript.Cues[m.activeCue].LineNumber
}
// SetPosition updates which cue is highlighted based on playback position
@@ -68,11 +65,11 @@ func (m *Model) SetPosition(pos time.Duration) {
}
newCue := m.transcript.CueIndexAt(pos)
if newCue != m.currentCue {
m.currentCue = newCue
if newCue != m.activeCue {
m.activeCue = newCue
m.updateContent()
// Only auto-scroll if not focused (let user navigate freely when focused)
if !m.Focused && newCue >= 0 {
// Always auto-scroll during playback
if newCue >= 0 {
m.scrollToCue(newCue)
}
}
@@ -87,22 +84,21 @@ func (m *Model) SetSize(width, height int) {
m.updateContent()
}
// SetFocused sets the focus state
func (m *Model) SetFocused(focused bool) {
m.Focused = focused
// When focusing, sync selected cue to current playback position if valid
if focused && m.currentCue >= 0 {
m.selectedCue = m.currentCue
m.updateContent()
m.scrollToCue(m.selectedCue)
}
}
// ModeString returns the mode as a string
func (m *Model) ModeString() string {
return "VIEW"
}
// seekToActiveCue returns a command to seek audio to the active cue
func (m *Model) seekToActiveCue() tea.Cmd {
if m.transcript == nil || m.activeCue < 0 || m.activeCue >= len(m.transcript.Cues) {
return nil
}
return func() tea.Msg {
return SeekToCueMsg{Position: m.transcript.Cues[m.activeCue].Start}
}
}
// Update handles messages
func (m *Model) Update(msg tea.Msg) tea.Cmd {
if m.transcript == nil {
@@ -113,63 +109,57 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd {
case tea.KeyMsg:
switch msg.String() {
case "j", "down":
// Move to next cue
if m.selectedCue < len(m.transcript.Cues)-1 {
m.selectedCue++
// Move to next cue and seek
if m.activeCue < len(m.transcript.Cues)-1 {
m.activeCue++
m.refreshAndScroll()
return m.seekToActiveCue()
}
return nil
case "k", "up":
// Move to previous cue
if m.selectedCue > 0 {
m.selectedCue--
// Move to previous cue and seek
if m.activeCue > 0 {
m.activeCue--
m.refreshAndScroll()
return m.seekToActiveCue()
}
return nil
case "ctrl+d":
// Jump 5 cues down
m.selectedCue += 5
if m.selectedCue >= len(m.transcript.Cues) {
m.selectedCue = len(m.transcript.Cues) - 1
// Jump 5 cues down and seek
m.activeCue += 5
if m.activeCue >= len(m.transcript.Cues) {
m.activeCue = len(m.transcript.Cues) - 1
}
m.refreshAndScroll()
return nil
return m.seekToActiveCue()
case "ctrl+u":
// Jump 5 cues up
m.selectedCue -= 5
if m.selectedCue < 0 {
m.selectedCue = 0
// Jump 5 cues up and seek
m.activeCue -= 5
if m.activeCue < 0 {
m.activeCue = 0
}
m.refreshAndScroll()
return nil
return m.seekToActiveCue()
case "g":
// Go to first cue
m.selectedCue = 0
// Go to first cue and seek
m.activeCue = 0
m.refreshAndScroll()
return nil
return m.seekToActiveCue()
case "G":
// Go to last cue
m.selectedCue = len(m.transcript.Cues) - 1
// Go to last cue and seek
m.activeCue = len(m.transcript.Cues) - 1
m.refreshAndScroll()
return nil
case "enter":
// Seek to selected cue
if m.selectedCue >= 0 && m.selectedCue < len(m.transcript.Cues) {
return func() tea.Msg {
return SeekToCueMsg{Position: m.transcript.Cues[m.selectedCue].Start}
}
}
return nil
return m.seekToActiveCue()
}
}
return nil
}
// refreshAndScroll updates content and scrolls to selected cue
// refreshAndScroll updates content and scrolls to active cue
func (m *Model) refreshAndScroll() {
m.updateContent()
m.scrollToCue(m.selectedCue)
m.scrollToCue(m.activeCue)
}
func (m *Model) updateContent() {
@@ -186,9 +176,8 @@ func (m *Model) updateContent() {
for i, cue := range m.transcript.Cues {
m.cueLines[i] = currentLine
isCurrent := i == m.currentCue
isSelected := i == m.selectedCue
rendered := RenderCue(&cue, isCurrent, isSelected, m.Width-4)
isActive := i == m.activeCue
rendered := RenderCue(&cue, isActive, m.Width-4)
sb.WriteString(rendered)
// Count lines in this cue's rendering
@@ -224,10 +213,7 @@ func (m *Model) scrollToCue(cueIndex int) {
func (m Model) View() string {
content := m.viewport.View()
style := ui.TranscriptStyle
if m.Focused {
style = ui.TranscriptFocusedStyle
}
style := ui.TranscriptFocusedStyle
return style.Width(m.Width - 2).Height(m.Height - 2).Render(content)
}

View File

@@ -13,7 +13,6 @@ import (
type Model struct {
Width int
Height int
Focused bool
Samples []float64
Position float64 // 0.0 to 1.0
Duration time.Duration
@@ -49,11 +48,6 @@ func (m *Model) SetSize(width, height int) {
m.Height = height
}
// SetFocused sets the focus state
func (m *Model) SetFocused(focused bool) {
m.Focused = focused
}
// View renders the waveform
func (m Model) View() string {
contentWidth := m.Width - 4 // Account for border and padding
@@ -115,11 +109,8 @@ func (m Model) View() string {
timeLine,
)
// Apply border style based on focus
style := ui.WaveformStyle
if m.Focused {
style = ui.WaveformFocusedStyle
}
// Apply focused border style
style := ui.WaveformFocusedStyle
return style.Width(m.Width - 2).Render(content)
}