diff --git a/ui/model/filebrowser.go b/ui/model/filebrowser.go index 75686929..6f7c1c2a 100644 --- a/ui/model/filebrowser.go +++ b/ui/model/filebrowser.go @@ -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 "/": diff --git a/ui/model/keymap.go b/ui/model/keymap.go index 2d79832a..f688d3a4 100644 --- a/ui/model/keymap.go +++ b/ui/model/keymap.go @@ -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": diff --git a/ui/model/keys.go b/ui/model/keys.go index 062f334a..9705e5d4 100644 --- a/ui/model/keys.go +++ b/ui/model/keys.go @@ -252,6 +252,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { m.lyrics.scroll++ } } + case "ctrl+x": + m.toggleExpandedView() } return nil } @@ -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() } @@ -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": @@ -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() @@ -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() } diff --git a/ui/model/lyrics.go b/ui/model/lyrics.go index c78b7dd2..081eb012 100644 --- a/ui/model/lyrics.go +++ b/ui/model/lyrics.go @@ -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)) +} diff --git a/ui/model/overlays.go b/ui/model/overlays.go index 23a21696..3d84c17e 100644 --- a/ui/model/overlays.go +++ b/ui/model/overlays.go @@ -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) @@ -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. @@ -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() diff --git a/ui/model/scroll.go b/ui/model/scroll.go index 3e531ad9..a235158f 100644 --- a/ui/model/scroll.go +++ b/ui/model/scroll.go @@ -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)) diff --git a/ui/model/state.go b/ui/model/state.go index b0c18537..7f54f3a2 100644 --- a/ui/model/state.go +++ b/ui/model/state.go @@ -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 } diff --git a/ui/model/update.go b/ui/model/update.go index f0c4ab4b..51da7c78 100644 --- a/ui/model/update.go +++ b/ui/model/update.go @@ -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) @@ -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 { @@ -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() @@ -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() @@ -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 { diff --git a/ui/model/view.go b/ui/model/view.go index 7e631dfb..e5df55a1 100644 --- a/ui/model/view.go +++ b/ui/model/view.go @@ -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, "") } @@ -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() diff --git a/ui/model/view_overlays.go b/ui/model/view_overlays.go index 00a37ae4..832a2e96 100644 --- a/ui/model/view_overlays.go +++ b/ui/model/view_overlays.go @@ -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 @@ -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")) } @@ -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"), "", @@ -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 @@ -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++ { @@ -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"))