Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ui/model/filebrowser.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ func (m *Model) handleFileBrowserKey(msg tea.KeyPressMsg) tea.Cmd {
m.fileBrowser.filtered = nil

case "ctrl+x":
m.toggleExpandPlaylist()
m.toggleExpandedView()
m.fbMaybeAdjustScroll(m.fbVisible())

case "/":
Expand Down
2 changes: 1 addition & 1 deletion ui/model/keymap.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ func (m *Model) handleKeymapKey(msg tea.KeyPressMsg) tea.Cmd {
m.keymapMaybeAdjustScroll(m.keymapVisible())

case "ctrl+x":
m.toggleExpandPlaylist()
m.toggleExpandedView()
m.keymapMaybeAdjustScroll(m.keymapVisible())

case "pgup", "ctrl+u":
Expand Down
58 changes: 48 additions & 10 deletions ui/model/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
m.lyrics.scroll++
}
}
case "ctrl+x":
m.toggleExpandedView()
}
return nil
}
Expand Down Expand Up @@ -358,7 +360,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
case "R":
return m.switchToProvider("radio")
case "ctrl+x":
m.toggleExpandPlaylist()
m.toggleExpandedView()
case "ctrl+f":
m.openProviderSearch()
}
Expand Down Expand Up @@ -748,7 +750,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {

case "ctrl+x":
if m.focus == focusPlaylist {
m.toggleExpandPlaylist()
m.toggleExpandedView()
}

case "x":
Expand Down Expand Up @@ -1023,8 +1025,8 @@ func (m *Model) updateProvSearch() {
}
}

// toggleExpandPlaylist toggles the playlist panel between default and expanded height.
func (m *Model) toggleExpandPlaylist() {
// toggleExpandedView toggles the UI between default and expanded height.
func (m *Model) toggleExpandedView() {
m.heightExpanded = !m.heightExpanded
m.applyHeightMode()
m.adjustScroll()
Expand Down Expand Up @@ -1632,24 +1634,60 @@ func (m *Model) handleThemeKey(msg tea.KeyPressMsg) tea.Cmd {
case "ctrl+c":
m.themePickerCancel()
return m.quit()

case "up", "k":
if m.themePicker.cursor > 0 {
m.themePicker.cursor--
m.themePickerApply() // live preview
} else {
} else if count > 0 {
m.themePicker.cursor = count - 1
m.themePickerApply() // live preview
}
m.themePickerApply()
m.themePickerMaybeAdjustScroll(m.themePickerVisible())

case "down", "j":
if m.themePicker.cursor < count-1 {
m.themePicker.cursor++
m.themePickerApply() // live preview
} else {
} else if count > 0 {
m.themePicker.cursor = 0
m.themePickerApply() // live preview
}
m.themePickerApply()
m.themePickerMaybeAdjustScroll(m.themePickerVisible())

case "ctrl+x":
m.toggleExpandedView()
m.themePickerMaybeAdjustScroll(m.themePickerVisible())

case "pgup", "ctrl+u":
if m.themePicker.cursor > 0 {
visible := m.themePickerVisible()
m.themePicker.cursor -= min(m.themePicker.cursor, visible)
m.themePickerApply()
m.themePickerMaybeAdjustScroll(visible)
}

case "pgdown", "ctrl+d":
if m.themePicker.cursor < count-1 {
visible := m.themePickerVisible()
m.themePicker.cursor = min(count-1, m.themePicker.cursor+visible)
m.themePickerApply()
m.themePickerMaybeAdjustScroll(visible)
}

case "home", "g":
m.themePicker.cursor = 0
m.themePickerApply()
m.themePickerMaybeAdjustScroll(m.themePickerVisible())

case "end", "G":
if count > 0 {
m.themePicker.cursor = count - 1
}
m.themePickerApply()
m.themePickerMaybeAdjustScroll(m.themePickerVisible())

case "enter":
m.themePickerSelect()

case "esc", "q", "t":
m.themePickerCancel()
}
Expand Down
10 changes: 10 additions & 0 deletions ui/model/lyrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,13 @@ func (m *Model) lyricsHaveTimestamps() bool {
}
return false
}

// lyricsVisibleHeight returns the number of lyrics lines to show.
func (m Model) lyricsVisibleHeight() int {
limit := maxPlVisible
if m.heightExpanded {
limit = m.height
}
// Fixed overhead: header (2) + spacing/footer (2) = 4.
return max(3, min(limit, m.height-4))
}
Comment on lines +57 to +66
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate renderLyricsOverlay and inspect its structure to count fixed sections.
ast-grep --pattern 'func ($_ Model) renderLyricsOverlay() string {
  $$$
}'

Repository: bjarneo/cliamp

Length of output: 4845


Hardcoded overhead - 4 is correct but deviates from the probe-based pattern used elsewhere.

The fixed overhead of 4 lines is accurate: renderLyricsOverlay has header (1) + spacing (1) at the top and footer spacing (1) + help key (1) at the bottom. However, this implementation diverges from the established pattern used by every other visible-height calculator (themePickerVisible, keymapVisible, fbVisible, etc.), which render a representative probe frame with lipgloss.Height() to stay resilient to layout changes.

If the lyrics overlay structure ever changes, this hardcoded value will silently become incorrect. Aligning with the probe-based pattern would eliminate that fragility:

♻️ Probe-based refactor
-// lyricsVisibleHeight returns the number of lyrics lines to show.
-func (m Model) lyricsVisibleHeight() int {
-	limit := maxPlVisible
-	if m.heightExpanded {
-		limit = m.height
-	}
-	// Fixed overhead: header (2) + spacing/footer (2) = 4.
-	return max(3, min(limit, m.height-4))
-}
+// lyricsVisibleHeight returns the number of lyrics lines to show.
+func (m *Model) lyricsVisibleHeight() int {
+	probeSections := []string{
+		titleStyle.Render("L Y R I C S"),
+		"",
+		"x",
+		"",
+		helpKey("Esc", "Close"),
+	}
+	probeFrame := ui.FrameStyle.Render(strings.Join(probeSections, "\n"))
+	fixedHeight := lipgloss.Height(probeFrame)
+	limit := maxPlVisible
+	if m.heightExpanded {
+		limit = m.height
+	}
+	return max(3, min(limit, m.height-fixedHeight))
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/model/lyrics.go` around lines 57 - 66, The lyricsVisibleHeight function
hardcodes an overhead of 4 which breaks the probe-based pattern used elsewhere;
replace the hardcoded subtraction with a probe-frame height calculation like the
other visible-height helpers: build a representative probe of the lyrics overlay
(mirroring renderLyricsOverlay's header/top spacing and footer/help key
structure), call lipgloss.Height on that probe, and use that value instead of
the literal 4 when computing return max(3, min(limit, m.height - probeHeight));
keep references to maxPlVisible, m.heightExpanded and m.height as in the current
function.

53 changes: 53 additions & 0 deletions ui/model/overlays.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package model
import (
"strings"

"charm.land/lipgloss/v2"

"cliamp/theme"
"cliamp/ui"
)

// openThemePicker re-loads themes from disk (picking up new user files)
Expand All @@ -15,6 +18,8 @@ func (m *Model) openThemePicker() {
// Position cursor on the currently active theme.
// Picker list: 0 = Default, 1..N = themes[0..N-1]
m.themePicker.cursor = m.themeIdx + 1
m.themePicker.scroll = 0
m.themePickerMaybeAdjustScroll(m.themePickerVisible())
}

// themePickerApply applies the theme under the cursor for live preview.
Expand Down Expand Up @@ -45,6 +50,54 @@ func (m *Model) themePickerCancel() {
m.themePicker.visible = false
}

func (m *Model) themePickerHelpLine() string {
return helpKey("↓↑", "Scroll ") + helpKey("Enter", "Select ") + helpKey("Esc", "Close")
}

func (m *Model) themePickerVisible() int {
probeSections := []string{
titleStyle.Render("T H E M E S"),
"",
"x", // 1-line list placeholder
"",
dimStyle.Render(" 0/0 themes"),
"",
m.themePickerHelpLine(),
}

probeFrame := ui.FrameStyle.Render(strings.Join(probeSections, "\n"))
fixedHeight := lipgloss.Height(probeFrame) - 1

limit := maxPlVisible
if m.heightExpanded {
limit = m.height
}
return max(3, min(limit, m.height-fixedHeight))
}

func (m *Model) themePickerMaybeAdjustScroll(visible int) {
if visible <= 0 {
return
}
count := len(m.themes) + 1
if m.themePicker.cursor < 0 {
m.themePicker.cursor = 0
}
if m.themePicker.cursor >= count && count > 0 {
m.themePicker.cursor = count - 1
}

if m.themePicker.cursor < m.themePicker.scroll {
m.themePicker.scroll = m.themePicker.cursor
} else if m.themePicker.cursor >= m.themePicker.scroll+visible {
m.themePicker.scroll = m.themePicker.cursor - visible + 1
}

if m.themePicker.scroll+visible > count && count > 0 {
m.themePicker.scroll = max(0, count-visible)
}
}

// openPlaylistManager loads playlist metadata and opens the manager overlay.
func (m *Model) openPlaylistManager() {
m.plMgrResetFilter()
Expand Down
5 changes: 3 additions & 2 deletions ui/model/scroll.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ func (m *Model) measurePlVisible(limit int) int {
probe := strings.Join([]string{
m.renderTitle(), m.renderTrackInfo(), m.renderTimeStatus(), "",
m.renderSpectrum(), m.renderSeekBar(), "",
m.renderControls(), "", m.renderPlaylistHeader(),
"x", "", m.renderHelp(), m.renderBottomStatus(),
m.renderControls(), m.renderProviderPill(), "",
m.renderPlaylistHeader(), "x", "",
m.renderHelp(), m.renderBottomStatus(),
}, "\n")
fixedLines := lipgloss.Height(ui.FrameStyle.Render(probe)) - 1
return max(3, min(limit, m.height-fixedLines))
Expand Down
1 change: 1 addition & 0 deletions ui/model/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ type seekState struct {
type themePickerState struct {
visible bool
cursor int
scroll int
savedIdx int // themeIdx before opening picker, for cancel/restore
}

Expand Down
14 changes: 14 additions & 0 deletions ui/model/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
seekCmd = cmd
}
// Expire temporary status messages.
wasStatus := m.status.text != ""
if !m.status.expiresAt.IsZero() && !now.Before(m.status.expiresAt) {
m.status.Clear()
}
// Drain app log buffer and expire old entries.
wasLogs := len(m.logLines)
m.tickLogLines(now)
if (wasStatus && m.status.text == "") || len(m.logLines) != wasLogs {
m.applyHeightMode()
m.adjustScroll()
}
m.tickPendingSpeedSave(dt)
// Decrement seek grace period.
advanceTickUnits(&m.seek.grace, &m.seek.graceFor, dt, ui.TickFast)
Expand All @@ -156,6 +162,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Poll ICY stream title for live radio display.
if title := m.player.StreamTitle(); title != "" && title != m.streamTitle {
m.streamTitle = title
m.applyHeightMode()
m.adjustScroll()
m.notifyAll()
// Auto-fetch lyrics when the stream song changes and lyrics overlay is open.
if m.lyrics.visible && !m.lyrics.loading {
Expand Down Expand Up @@ -296,6 +304,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.plCursor = 0
m.plScroll = 0
m.focus = focusPlaylist
m.applyHeightMode()
m.adjustScroll()
m.provLoading = false
if m.playlist.Len() > 0 && !wasPlaying {
cmd := m.playCurrentTrack()
Expand Down Expand Up @@ -413,6 +423,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.setInitialHeaderState(msg.tracks)
m.plCursor = 0
m.plScroll = 0
m.applyHeightMode()
m.adjustScroll()
m.status.Showf(statusTTLDefault, "Loaded %d episode(s)", len(msg.tracks))
playCmd := m.playCurrentTrack()
m.notifyAll()
Expand Down Expand Up @@ -485,6 +497,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.playlist.Add(msg.tracks...)
}
m.focus = focusPlaylist
m.applyHeightMode()
m.adjustScroll()
m.status.Showf(statusTTLDefault, "Added %d track(s)", len(msg.tracks))
if !m.player.IsPlaying() && m.playlist.Len() > 0 {
if msg.replace {
Expand Down
12 changes: 10 additions & 2 deletions ui/model/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ func (m Model) renderProviderEmptyState(budget int) string {
lines = append(lines, dimStyle.Render(" "+hint))
}
}
lines = lines[:min(len(lines), budget)]
for len(lines) < budget {
lines = append(lines, "")
}
Expand Down Expand Up @@ -696,10 +697,17 @@ func (m Model) renderPlaylist() string {

tracks := m.playlist.Tracks()
if len(tracks) == 0 {
var lines []string
if m.feedLoading {
return loadingLine("Loading feed…")
lines = append(lines, loadingLine("Loading feed…"))
} else {
lines = append(lines, dimStyle.Render(" No tracks loaded"))
}
lines = lines[:min(len(lines), budget)]
for len(lines) < budget {
lines = append(lines, "")
}
return dimStyle.Render(" No tracks loaded")
return strings.Join(lines, "\n")
}

currentIdx := m.playlist.Index()
Expand Down
22 changes: 10 additions & 12 deletions ui/model/view_overlays.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ func (m Model) renderThemePicker() string {
}

count := len(m.themes) + 1
maxVisible := 15
scroll := scrollStart(m.themePicker.cursor, maxVisible)
maxVisible := m.themePickerVisible()
scroll := m.themePicker.scroll
rendered := 0

for i := scroll; i < count && i < scroll+maxVisible; i++ {
var name string
Expand All @@ -78,13 +79,12 @@ func (m Model) renderThemePicker() string {
name = m.themes[i-1].Name
}
lines = append(lines, cursorLine(name, i == m.themePicker.cursor))
rendered++
}

if count > maxVisible {
lines = append(lines, "", dimStyle.Render(fmt.Sprintf(" %d/%d themes", m.themePicker.cursor+1, count)))
}

lines = append(lines, "", helpKey("↓↑", "Scroll ")+helpKey("Enter", "Select ")+helpKey("Esc", "Cancel"))
lines = padLines(lines, maxVisible, rendered)
lines = append(lines, "", dimStyle.Render(fmt.Sprintf(" %d/%d themes", m.themePicker.cursor+1, count)))
lines = append(lines, "", m.themePickerHelpLine())

return m.centerOverlay(strings.Join(lines, "\n"))
}
Expand Down Expand Up @@ -496,6 +496,7 @@ func (m Model) renderURLInputOverlay() string {
}

func (m Model) renderLyricsOverlay() string {
visible := m.lyricsVisibleHeight()
lines := []string{
titleStyle.Render("L Y R I C S"),
"",
Expand Down Expand Up @@ -532,7 +533,6 @@ func (m Model) renderLyricsOverlay() string {
}
}

visible := max(m.height-8, 5)
half := visible / 2
startIdx := max(activeIdx-half, 0)
endIdx := startIdx + visible
Expand All @@ -554,7 +554,6 @@ func (m Model) renderLyricsOverlay() string {
}
} else {
// Scroll mode: manual navigation with j/k or arrow keys.
visible := max(m.height-8, 5)
endIdx := min(m.lyrics.scroll+visible, len(m.lyrics.lines))

for i := m.lyrics.scroll; i < endIdx; i++ {
Expand All @@ -566,9 +565,8 @@ func (m Model) renderLyricsOverlay() string {
}
}

for len(lines) < 14 {
lines = append(lines, "")
}
rendered := len(lines) - 2 // -2 for header and spacing
lines = padLines(lines, visible, rendered)

if m.lyricsSyncable() && m.lyricsHaveTimestamps() {
lines = append(lines, "", helpKey("Esc", "Close"))
Expand Down