From 80cdd2168986eeece2ef41d7ee6d28201b97dfb3 Mon Sep 17 00:00:00 2001 From: Nicholas Zambetti Date: Tue, 31 Mar 2026 17:58:45 +0200 Subject: [PATCH 1/3] Merge responsive height feature --- ui/model/filebrowser.go | 147 +++++++++++++++++++++++++++------- ui/model/keys.go | 169 ++++++++++++++++++++++++++++++++-------- ui/model/model.go | 6 +- ui/model/providers.go | 19 ++++- ui/model/scroll.go | 42 +++++++++- ui/model/state.go | 1 + ui/model/update.go | 14 +++- ui/model/view.go | 111 ++++++++++++++++++-------- 8 files changed, 410 insertions(+), 99 deletions(-) diff --git a/ui/model/filebrowser.go b/ui/model/filebrowser.go index 24a731b..3d03597 100644 --- a/ui/model/filebrowser.go +++ b/ui/model/filebrowser.go @@ -7,6 +7,7 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "cliamp/player" "cliamp/playlist" @@ -32,6 +33,93 @@ type fbTracksResolvedMsg struct { replace bool } +// fbExpanded reports whether the shared playlist/file-browser height is currently expanded. +func (m *Model) fbExpanded() bool { + return m.heightExpanded +} + +// fbVisibleCap returns the max list height cap based on expanded mode. +func (m *Model) fbVisibleCap() int { + if m.fbExpanded() { + return m.height + } + return fbMaxVisible +} + +func (m Model) fbHeaderLines() []string { + return []string{ + titleStyle.Render("O P E N F I L E S"), + dimStyle.Render(" " + m.fileBrowser.dir), + "", + } +} + +func (m Model) fbHelpLine() string { + help := helpKey("↑↓", "Scroll ") + helpKey("Enter", "Open ") + + helpKey("Spc", "Select ") + helpKey("a", "All ") + + helpKey("←", "Back ") + helpKey("~.", "Home/Cwd ") + if os.PathSeparator == '\\' { + help += helpKey("AltCZ", "Drive ") + } + if len(m.fileBrowser.selected) > 0 { + help += helpKey("R", "Replace ") + } + help += helpKey("Esc", "Close") + return help +} + +// fbVisible returns the current file-browser list height accounting for +// frame padding and all fixed (non-list) sections. +func (m *Model) fbVisible() int { + probeSections := append([]string{}, m.fbHeaderLines()...) + if m.fileBrowser.err != "" { + probeSections = append(probeSections, errorStyle.Render(" "+m.fileBrowser.err)) + } + + // 1-line list placeholder. + probeSections = append(probeSections, "x") + + // Footer area must mirror renderFileBrowser(). + if len(m.fileBrowser.selected) > 0 { + probeSections = append(probeSections, "", statusStyle.Render(" 1 selected")) + } else { + probeSections = append(probeSections, "") + if m.fileBrowser.err == "" { + probeSections = append(probeSections, "") + } + } + probeSections = append(probeSections, "", m.fbHelpLine()) + + probeFrame := ui.FrameStyle.Render(strings.Join(probeSections, "\n")) + fixedHeight := lipgloss.Height(probeFrame) - 1 + + limit := m.fbVisibleCap() + return max(3, min(limit, m.height-fixedHeight)) +} + +// fbMaybeAdjustScroll keeps the cursor visible in the current file-browser window. +func (m *Model) fbMaybeAdjustScroll(visible int) { + if visible <= 0 { + return + } + if m.fileBrowser.cursor < 0 { + m.fileBrowser.cursor = 0 + } + if m.fileBrowser.cursor >= len(m.fileBrowser.entries) && len(m.fileBrowser.entries) > 0 { + m.fileBrowser.cursor = len(m.fileBrowser.entries) - 1 + } + + if m.fileBrowser.cursor < m.fileBrowser.scroll { + m.fileBrowser.scroll = m.fileBrowser.cursor + } else if m.fileBrowser.cursor >= m.fileBrowser.scroll+visible { + m.fileBrowser.scroll = m.fileBrowser.cursor - visible + 1 + } + + if m.fileBrowser.scroll+visible > len(m.fileBrowser.entries) { + m.fileBrowser.scroll = max(0, len(m.fileBrowser.entries)-visible) + } +} + // openFileBrowser initialises and shows the file browser overlay. func (m *Model) openFileBrowser() { if m.fileBrowser.dir == "" { @@ -41,6 +129,7 @@ func (m *Model) openFileBrowser() { } } m.fileBrowser.cursor = 0 + m.fileBrowser.scroll = 0 m.fileBrowser.selected = make(map[string]bool) m.fileBrowser.err = "" m.loadFBDir() @@ -51,6 +140,7 @@ func (m *Model) openFileBrowser() { func (m *Model) loadFBDir() { m.fileBrowser.err = "" m.fileBrowser.cursor = 0 + m.fileBrowser.scroll = 0 clear(m.fileBrowser.selected) // Reuse internal memory buffer of m.fileBrowser.entries. @@ -130,12 +220,17 @@ func (m *Model) handleFileBrowserKey(msg tea.KeyPressMsg) tea.Cmd { case "esc", "o", "q": m.fileBrowser.visible = false + case "x": + m.toggleExpandPlaylist() + m.fbMaybeAdjustScroll(m.fbVisible()) + case "up", "k": if m.fileBrowser.cursor > 0 { m.fileBrowser.cursor-- } else if len(m.fileBrowser.entries) > 0 { m.fileBrowser.cursor = len(m.fileBrowser.entries) - 1 } + m.fbMaybeAdjustScroll(m.fbVisible()) case "down", "j": if m.fileBrowser.cursor < len(m.fileBrowser.entries)-1 { @@ -143,15 +238,20 @@ func (m *Model) handleFileBrowserKey(msg tea.KeyPressMsg) tea.Cmd { } else if len(m.fileBrowser.entries) > 0 { m.fileBrowser.cursor = 0 } + m.fbMaybeAdjustScroll(m.fbVisible()) case "pgup", "ctrl+u": if m.fileBrowser.cursor > 0 { - m.fileBrowser.cursor -= min(m.fileBrowser.cursor, fbMaxVisible) + jump := m.fbVisible() + m.fileBrowser.cursor -= min(m.fileBrowser.cursor, jump) + m.fbMaybeAdjustScroll(m.fbVisible()) } case "pgdown", "ctrl+d": if m.fileBrowser.cursor < len(m.fileBrowser.entries)-1 { - m.fileBrowser.cursor = min(len(m.fileBrowser.entries)-1, m.fileBrowser.cursor+fbMaxVisible) + jump := m.fbVisible() + m.fileBrowser.cursor = min(len(m.fileBrowser.entries)-1, m.fileBrowser.cursor+jump) + m.fbMaybeAdjustScroll(m.fbVisible()) } case "enter", "l", "right": @@ -172,6 +272,7 @@ func (m *Model) handleFileBrowserKey(msg tea.KeyPressMsg) tea.Cmd { break } } + m.fbMaybeAdjustScroll(m.fbVisible()) } } else if e.isAudio { m.fileBrowser.selected[e.path] = true @@ -190,6 +291,7 @@ func (m *Model) handleFileBrowserKey(msg tea.KeyPressMsg) tea.Cmd { break } } + m.fbMaybeAdjustScroll(m.fbVisible()) case "~": if cd, _ = os.UserHomeDir(); cd != "" && m.fileBrowser.dir != cd { @@ -217,6 +319,7 @@ func (m *Model) handleFileBrowserKey(msg tea.KeyPressMsg) tea.Cmd { m.fileBrowser.cursor++ } } + m.fbMaybeAdjustScroll(m.fbVisible()) case "a": // Toggle select all audio files in current view. @@ -235,11 +338,13 @@ func (m *Model) handleFileBrowserKey(msg tea.KeyPressMsg) tea.Cmd { case "g", "home": m.fileBrowser.cursor = 0 + m.fbMaybeAdjustScroll(m.fbVisible()) case "G", "end": if len(m.fileBrowser.entries) > 0 { m.fileBrowser.cursor = len(m.fileBrowser.entries) - 1 } + m.fbMaybeAdjustScroll(m.fbVisible()) case "R": if len(m.fileBrowser.selected) > 0 { @@ -262,7 +367,7 @@ func (m *Model) handleFileBrowserKey(msg tea.KeyPressMsg) tea.Cmd { // fbConfirm collects selected paths, closes the overlay, and returns an async // command that resolves the paths into tracks. func (m *Model) fbConfirm(replace bool) tea.Cmd { - var paths = make([]string, 0, len(m.fileBrowser.selected)) + paths := make([]string, 0, len(m.fileBrowser.selected)) for p := range m.fileBrowser.selected { paths = append(paths, p) } @@ -279,11 +384,8 @@ func (m *Model) fbConfirm(replace bool) tea.Cmd { // renderFileBrowser renders the file browser overlay. func (m Model) renderFileBrowser() string { - lines := append(make([]string, 0, 3+fbMaxVisible+4), - titleStyle.Render("O P E N F I L E S"), - dimStyle.Render(" "+m.fileBrowser.dir), - "", - ) + maxVisible := m.fbVisible() + lines := append(make([]string, 0, 3+maxVisible+4), m.fbHeaderLines()...) if m.fileBrowser.err != "" { lines = append(lines, errorStyle.Render(" "+m.fileBrowser.err)) @@ -295,12 +397,15 @@ func (m Model) renderFileBrowser() string { lines = append(lines, dimStyle.Render(" (empty)")) rendered = 1 } else { - scroll := 0 - if m.fileBrowser.cursor >= fbMaxVisible { - scroll = m.fileBrowser.cursor - fbMaxVisible + 1 + scroll := m.fileBrowser.scroll + if scroll < 0 { + scroll = 0 + } + if scroll > len(m.fileBrowser.entries)-1 { + scroll = max(0, len(m.fileBrowser.entries)-1) } - for i := scroll; i < len(m.fileBrowser.entries) && i < scroll+fbMaxVisible; i++ { + for i := scroll; i < len(m.fileBrowser.entries) && i < scroll+maxVisible; i++ { e := m.fileBrowser.entries[i] // Selection check mark. @@ -318,7 +423,7 @@ func (m Model) renderFileBrowser() string { label := check + e.name + suffix // Truncate long names. - maxW := ui.PanelWidth - 4 + maxW := max(1, ui.PanelWidth-2) labelRunes := []rune(label) if len(labelRunes) > maxW { label = string(labelRunes[:maxW-1]) + "…" @@ -338,7 +443,7 @@ func (m Model) renderFileBrowser() string { } // Pad to fixed height. - for range fbMaxVisible - rendered { + for i := 0; i < maxVisible-rendered; i++ { lines = append(lines, "") } @@ -347,23 +452,13 @@ func (m Model) renderFileBrowser() string { lines = append(lines, "", statusStyle.Render(fmt.Sprintf(" %d selected", len(m.fileBrowser.selected)))) } else { lines = append(lines, "") - // Pad to fixed height. + // Keep footer alignment consistent when no error/status line is present. if m.fileBrowser.err == "" { lines = append(lines, "") } } - help := helpKey("↑↓", "Scroll ") + helpKey("Enter", "Open ") + - helpKey("Spc", "Select ") + helpKey("a", "All ") + - helpKey("←", "Back ") + helpKey("~.", "Home/Cwd ") - if os.PathSeparator == '\\' { - help += helpKey("AltCZ", "Drive ") - } - if len(m.fileBrowser.selected) > 0 { - help += helpKey("R", "Replace ") - } - help += helpKey("Esc", "Close") - lines = append(lines, "", help) + lines = append(lines, "", m.fbHelpLine()) return m.centerOverlay(strings.Join(lines, "\n")) } diff --git a/ui/model/keys.go b/ui/model/keys.go index fa4c024..eb55315 100644 --- a/ui/model/keys.go +++ b/ui/model/keys.go @@ -9,7 +9,6 @@ import ( "time" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "cliamp/internal/fileutil" "cliamp/playlist" @@ -68,6 +67,118 @@ func (m *Model) handleSpeedKey(msg tea.KeyPressMsg) tea.Cmd { return nil } +func (m *Model) providerScrollStep() int { + return max(1, m.effectivePlaylistVisible()) +} + +func (m *Model) providerMaybeAdjustScroll() { + visible := m.providerScrollStep() + total := len(m.providerLists) + if total == 0 { + m.provScroll = 0 + return + } + + if m.provCursor < m.provScroll { + m.provScroll = m.provCursor + } + + // Sectioned providers (e.g. radio) render extra header rows, so + // cursor visibility must be computed in rendered rows, not item count. + if sl, ok := m.provider.(provider.SectionedList); ok { + if m.provScroll >= total { + m.provScroll = max(0, total-1) + } + + // Only push down when needed to keep the cursor visible. + // Do not "pull up" aggressively, which can make paging feel jumpy + // and keep the cursor stuck near the bottom of the viewport. + for m.provScroll < total && m.providerRowsFromScroll(sl, m.provScroll, m.provCursor) > visible { + m.provScroll++ + } + return + } + + // Non-sectioned providers: regular item-count based scrolling. + if m.provCursor >= m.provScroll+visible { + m.provScroll = m.provCursor - visible + 1 + } + if m.provScroll+visible > total { + m.provScroll = max(0, total-visible) + } +} + +func (m *Model) providerRowsFromScroll(sl provider.SectionedList, scroll, cursor int) int { + total := len(m.providerLists) + if total == 0 || cursor < scroll || scroll < 0 || cursor >= total { + return 0 + } + + rows := 0 + prevPrefix := "" + if scroll > 0 { + prevPrefix = sl.IDPrefix(m.providerLists[scroll-1].ID) + } + + for i := scroll; i <= cursor && i < total; i++ { + pfx := sl.IDPrefix(m.providerLists[i].ID) + if pfx != prevPrefix { + rows++ // section header row + } + rows++ // item row + prevPrefix = pfx + } + return rows +} + +func (m *Model) providerMoveUp() { + if m.provCursor > 0 { + m.provCursor-- + } else if len(m.providerLists) > 0 { + m.provCursor = len(m.providerLists) - 1 + } + m.providerMaybeAdjustScroll() +} + +func (m *Model) providerMoveDown() { + if m.provCursor < len(m.providerLists)-1 { + m.provCursor++ + } else if len(m.providerLists) > 0 { + m.provCursor = 0 + } + m.providerMaybeAdjustScroll() +} + +func (m *Model) providerPageUp() { + if m.provCursor > 0 { + m.provCursor -= min(m.provCursor, m.providerScrollStep()) + } + // Top-anchor behavior: place cursor at top of viewport when paging up. + m.provScroll = m.provCursor + m.providerMaybeAdjustScroll() +} + +func (m *Model) providerPageDown() { + if m.provCursor < len(m.providerLists)-1 { + m.provCursor = min(len(m.providerLists)-1, m.provCursor+m.providerScrollStep()) + } + // Bottom-anchor behavior: bias viewport so cursor lands near bottom when paging down. + m.provScroll = max(0, m.provCursor-m.providerScrollStep()+1) + m.providerMaybeAdjustScroll() +} + +func (m *Model) providerToTop() { + m.provCursor = 0 + m.providerMaybeAdjustScroll() +} + +func (m *Model) providerToBottom() { + if len(m.providerLists) > 0 { + m.provCursor = len(m.providerLists) - 1 + } + m.providerMaybeAdjustScroll() +} + // handleKey processes a single key press and returns an optional command. func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { if m.keymap.visible { @@ -169,19 +280,11 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { case "q", "ctrl+c": return m.quit() case "up", "k": - if m.provCursor > 0 { - m.provCursor-- - } else if len(m.providerLists) > 0 { - m.provCursor = len(m.providerLists) - 1 - } - case "space": + m.providerMoveUp() + case " ": return m.togglePlayPause() case "down", "j": - if m.provCursor < len(m.providerLists)-1 { - m.provCursor++ - } else if len(m.providerLists) > 0 { - m.provCursor = 0 - } + m.providerMoveDown() // Auto-load next catalog page when scrolling near the bottom. return m.maybeLoadCatalogBatch() case "enter": @@ -221,20 +324,14 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { m.openNavBrowserWith(prov) } case "pgup", "ctrl+u": - if m.provCursor > 0 { - m.provCursor -= min(m.provCursor, m.plVisible) - } + m.providerPageUp() case "pgdown", "ctrl+d": - if m.provCursor < len(m.providerLists)-1 { - m.provCursor = min(len(m.providerLists)-1, m.provCursor+m.plVisible) - } + m.providerPageDown() return m.maybeLoadCatalogBatch() case "g", "home": - m.provCursor = 0 + m.providerToTop() case "G", "end": - if len(m.providerLists) > 0 { - m.provCursor = len(m.providerLists) - 1 - } + m.providerToBottom() return m.maybeLoadCatalogBatch() case "ctrl+j": m.openJumpMode() @@ -283,7 +380,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { m.vis.Rows = ui.DefaultVisRows m.restorePanelWidth() } else if m.focus == focusPlaylist { - m.plVisible = m.defaultPlVisible() + // Keep current expanded/collapsed height mode when switching focus. m.focus = focusProvider } @@ -600,6 +697,12 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { case "v": m.vis.CycleMode() + if m.heightExpanded { + m.plVisible = m.expandedPlVisible() + } else { + m.plVisible = m.collapsedPlVisible() + } + m.adjustScroll() if err := m.configSaver.Save("visualizer", fmt.Sprintf("%q", m.vis.ModeName())); err != nil { m.status.Showf(statusTTLDefault, "Config save failed: %s", err) } @@ -768,6 +871,7 @@ func (m *Model) handleProvSearchKey(msg tea.KeyPressMsg) tea.Cmd { if len(m.provSearch.results) > 0 && !m.provLoading { idx := m.provSearch.results[m.provSearch.cursor] m.provCursor = idx + m.providerMaybeAdjustScroll() m.provLoading = true m.provSearch.active = false return fetchTracksCmd(m.provider, m.providerLists[idx].ID) @@ -836,6 +940,7 @@ func (m *Model) restoreCatalog(cs provider.CatalogSearcher) { m.providerLists = lists } m.provCursor = 0 + m.provScroll = 0 } func (m *Model) updateProvSearch() { @@ -854,18 +959,14 @@ func (m *Model) updateProvSearch() { // toggleExpandPlaylist toggles the playlist panel between default and expanded height. func (m *Model) toggleExpandPlaylist() { - defVis := m.defaultPlVisible() - if m.plVisible <= defVis { - probe := strings.Join([]string{ - m.renderTitle(), m.renderTrackInfo(), m.renderTimeStatus(), "", - m.renderSpectrum(), m.renderSeekBar(), "", - m.renderControls(), "", m.renderPlaylistHeader(), - "x", "", m.renderHelp(), m.renderBottomStatus(), - }, "\n") - fixedLines := lipgloss.Height(ui.FrameStyle.Render(probe)) - 1 - m.plVisible = max(minPlVisible, min(maxPlExpandVisible, m.height-fixedLines)) + collapsed := m.collapsedPlVisible() + expanded := m.expandedPlVisible() + + m.heightExpanded = !m.heightExpanded + if m.heightExpanded { + m.plVisible = expanded } else { - m.plVisible = defVis + m.plVisible = collapsed } m.adjustScroll() } diff --git a/ui/model/model.go b/ui/model/model.go index 317aaa6..1b2a5d3 100644 --- a/ui/model/model.go +++ b/ui/model/model.go @@ -136,6 +136,7 @@ type Model struct { localProvider playlist.Provider // local playlist provider for file-based playlist management (always available) providerLists []playlist.PlaylistInfo provCursor int + provScroll int provLoading bool provSignIn bool // true when provider needs interactive sign-in providers []ProviderEntry // all available providers @@ -223,8 +224,9 @@ type Model struct { // Full-screen visualizer mode (Shift+V) fullVis bool - autoPlay bool // start playing immediately on launch - compact bool // compact mode: cap frame width at 80 columns + autoPlay bool // start playing immediately on launch + compact bool // compact mode: cap frame width at 80 columns + heightExpanded bool // tracks whether manual 'x' expansion is active // Cached per-tick to avoid repeated speaker.Lock() calls in View(). cachedPos time.Duration diff --git a/ui/model/providers.go b/ui/model/providers.go index 213d94d..5e92286 100644 --- a/ui/model/providers.go +++ b/ui/model/providers.go @@ -14,7 +14,13 @@ import ( func (m *Model) StartInProvider() { if m.provider != nil { m.focus = focusProvider + m.provCursor = 0 + m.provScroll = 0 m.provLoading = true + m.provSearch.active = false + m.provSearch.query = "" + m.provSearch.results = nil + m.provSearch.cursor = 0 } } @@ -25,12 +31,23 @@ func (m *Model) switchProvider(idx int) tea.Cmd { } m.provPillIdx = idx m.provider = m.providers[idx].Provider + + // Reset provider list state so navigation always starts from the top. m.providerLists = nil m.provCursor = 0 + m.provScroll = 0 m.provLoading = true m.provSignIn = false + + // Reset provider search state to avoid stale filtered positions. m.provSearch.active = false - m.catalogBatch = catalogBatchState{} // reset catalog batch for new provider + m.provSearch.query = "" + m.provSearch.results = nil + m.provSearch.cursor = 0 + + // Reset catalog batching state for lazy-loaded providers. + m.catalogBatch = catalogBatchState{} + m.focus = focusProvider return fetchPlaylistsCmd(m.provider) } diff --git a/ui/model/scroll.go b/ui/model/scroll.go index 75f6604..d4bdb5a 100644 --- a/ui/model/scroll.go +++ b/ui/model/scroll.go @@ -5,12 +5,30 @@ import ( "charm.land/lipgloss/v2" + "cliamp/playlist" "cliamp/ui" ) -// defaultPlVisible recalculates the natural plVisible for the current terminal -// height (same logic as the window-resize handler, capped at maxPlVisible). -func (m *Model) defaultPlVisible() int { +// renderedLineCount returns how many rendered lines tracks[from..to) would +// take, including album separator lines between different albums. +func renderedLineCount(tracks []playlist.Track, from, to int) int { + lines := 0 + prevAlbum := "" + if from > 0 { + prevAlbum = tracks[from-1].Album + } + for i := from; i < to && i < len(tracks); i++ { + if album := tracks[i].Album; album != "" && album != prevAlbum { + lines++ // album separator + } + prevAlbum = tracks[i].Album + lines++ // track line + } + return lines +} + +// measurePlVisible calculates playlist lines available for a given upper limit. +func (m *Model) measurePlVisible(limit int) int { saved := m.plVisible m.plVisible = 3 // temporary minimal value for measurement defer func() { m.plVisible = saved }() @@ -21,7 +39,23 @@ func (m *Model) defaultPlVisible() int { "x", "", m.renderHelp(), m.renderBottomStatus(), }, "\n") fixedLines := lipgloss.Height(ui.FrameStyle.Render(probe)) - 1 - return max(3, min(maxPlVisible, m.height-fixedLines)) + return max(3, min(limit, m.height-fixedLines)) +} + +// collapsedPlVisible returns the natural (non-expanded) playlist height. +func (m *Model) collapsedPlVisible() int { + return m.measurePlVisible(maxPlVisible) +} + +// expandedPlVisible returns the expanded playlist height with no cap. +func (m *Model) expandedPlVisible() int { + return m.measurePlVisible(m.height) +} + +// defaultPlVisible returns the collapsed baseline height. +// Keep this stable so toggle logic can reliably compare/collapse. +func (m *Model) defaultPlVisible() int { + return m.collapsedPlVisible() } // adjustScroll ensures plCursor is visible in the playlist view. diff --git a/ui/model/state.go b/ui/model/state.go index c1d8827..83378b7 100644 --- a/ui/model/state.go +++ b/ui/model/state.go @@ -95,6 +95,7 @@ type fileBrowserState struct { dir string entries []fbEntry cursor int + scroll int selected map[string]bool err string } diff --git a/ui/model/update.go b/ui/model/update.go index 154e0d9..58d9a3a 100644 --- a/ui/model/update.go +++ b/ui/model/update.go @@ -64,7 +64,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.vis.Rows = max(ui.DefaultVisRows, (m.height-10)*4/5) ui.PanelWidth = max(0, m.width-2*ui.PaddingH) } - m.plVisible = m.defaultPlVisible() + if m.heightExpanded { + m.plVisible = m.expandedPlVisible() + } else { + m.plVisible = m.collapsedPlVisible() + } + m.adjustScroll() + if m.focus == focusProvider { + m.providerMaybeAdjustScroll() + } + if m.fileBrowser.visible { + m.fbMaybeAdjustScroll(m.fbVisible()) + } return m, nil case seekTickMsg: @@ -353,6 +364,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.providerLists = lists } m.provCursor = 0 + m.provScroll = 0 if msg.count == 0 { m.status.Show("No stations found", statusTTLDefault) } diff --git a/ui/model/view.go b/ui/model/view.go index 14b708f..43ed44d 100644 --- a/ui/model/view.go +++ b/ui/model/view.go @@ -489,7 +489,6 @@ func (m Model) renderProviderList() string { } sl, isRadio := m.provider.(provider.SectionedList) - var lines []string if m.provSearch.active { @@ -522,48 +521,95 @@ func (m Model) renderProviderList() string { lines = append(lines, dimStyle.Render(fmt.Sprintf(" %d/%d playlists", len(m.provSearch.results), len(m.providerLists)))) } } - return strings.Join(lines, "\n") - } - - visible := min(visibleBudget, len(m.providerLists)) - scroll := max(0, m.provCursor-visible+1) - prevPrefix := "" - - for j := scroll; j < scroll+visible && j < len(m.providerLists); j++ { - p := m.providerLists[j] + } else { + scroll := max(0, m.provScroll) + if scroll >= len(m.providerLists) { + scroll = max(0, len(m.providerLists)-1) + } + if m.provCursor < scroll { + scroll = m.provCursor + } - // Insert section separators. if isRadio { - pfx := sl.IDPrefix(p.ID) - if pfx != prevPrefix { - switch pfx { - case "f": - lines = append(lines, dimStyle.Render(" ── favorites ──")) - visible++ - case "c": - lines = append(lines, dimStyle.Render(" ── catalog ──")) - visible++ - case "s": - lines = append(lines, dimStyle.Render(" ── search results ──")) - visible++ + rowsFrom := func(start, cursor int) int { + if start < 0 || cursor < start || cursor >= len(m.providerLists) { + return 0 } - prevPrefix = pfx + rows := 0 + prevPrefix := "" + if start > 0 { + prevPrefix = sl.IDPrefix(m.providerLists[start-1].ID) + } + for i := start; i <= cursor; i++ { + pfx := sl.IDPrefix(m.providerLists[i].ID) + if pfx != prevPrefix { + rows++ + } + rows++ + prevPrefix = pfx + } + return rows } + for scroll < len(m.providerLists)-1 && rowsFrom(scroll, m.provCursor) > visibleBudget { + scroll++ + } + } else if m.provCursor >= scroll+visibleBudget { + scroll = m.provCursor - visibleBudget + 1 } - prefix, style := " ", playlistItemStyle - if j == m.provCursor { - style = playlistSelectedStyle - prefix = "> " + prevPrefix := "" + if isRadio && scroll > 0 { + prevPrefix = sl.IDPrefix(m.providerLists[scroll-1].ID) + } + + for j := scroll; j < len(m.providerLists) && len(lines) < visibleBudget; j++ { + p := m.providerLists[j] + + if isRadio { + pfx := sl.IDPrefix(p.ID) + if pfx != prevPrefix { + var header string + switch pfx { + case "f": + header = " ── favorites ──" + case "c": + header = " ── catalog ──" + case "s": + header = " ── search results ──" + } + if header != "" && len(lines) < visibleBudget { + lines = append(lines, dimStyle.Render(header)) + } + prevPrefix = pfx + } + } + + if len(lines) >= visibleBudget { + break + } + + prefix, style := " ", playlistItemStyle + if j == m.provCursor { + style = playlistSelectedStyle + prefix = "> " + } + lines = append(lines, style.Render(playlistLabel(prefix, p))) } - lines = append(lines, style.Render(playlistLabel(prefix, p))) } - // Loading indicator for catalog batch. - if isRadio && m.catalogBatch.loading { + // Loading indicator for catalog batch (never displace selected row if full). + if isRadio && m.catalogBatch.loading && len(lines) < visibleBudget { lines = append(lines, dimStyle.Render(" Loading more stations...")) } + // Clamp exactly to visible budget so footer/help remain visible. + if len(lines) > visibleBudget { + lines = lines[:visibleBudget] + } + for len(lines) < visibleBudget { + lines = append(lines, "") + } + return strings.Join(lines, "\n") } @@ -645,6 +691,9 @@ func (m Model) renderPlaylist() string { lines = append(lines, line) } + for len(lines) < budget { + lines = append(lines, "") + } return strings.Join(lines, "\n") } From 878b6338efac3c2a999b86a666f25b2668300aba Mon Sep 17 00:00:00 2001 From: Nicholas Zambetti Date: Tue, 31 Mar 2026 22:42:55 +0200 Subject: [PATCH 2/3] Inline some single use helpers --- ui/model/filebrowser.go | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/ui/model/filebrowser.go b/ui/model/filebrowser.go index 3d03597..a602d46 100644 --- a/ui/model/filebrowser.go +++ b/ui/model/filebrowser.go @@ -33,19 +33,6 @@ type fbTracksResolvedMsg struct { replace bool } -// fbExpanded reports whether the shared playlist/file-browser height is currently expanded. -func (m *Model) fbExpanded() bool { - return m.heightExpanded -} - -// fbVisibleCap returns the max list height cap based on expanded mode. -func (m *Model) fbVisibleCap() int { - if m.fbExpanded() { - return m.height - } - return fbMaxVisible -} - func (m Model) fbHeaderLines() []string { return []string{ titleStyle.Render("O P E N F I L E S"), @@ -93,7 +80,10 @@ func (m *Model) fbVisible() int { probeFrame := ui.FrameStyle.Render(strings.Join(probeSections, "\n")) fixedHeight := lipgloss.Height(probeFrame) - 1 - limit := m.fbVisibleCap() + limit := fbMaxVisible + if m.heightExpanded { + limit = m.height + } return max(3, min(limit, m.height-fixedHeight)) } From 6b0215729a07fc9649dc931acb97d0ca374826a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bjarne=20=C3=98verli?= Date: Sat, 4 Apr 2026 20:45:23 +0200 Subject: [PATCH 3/3] Simplify responsive height: remove dead code, deduplicate helpers - Remove unused renderedLineCount() and defaultPlVisible() - Fix space key representation to match bubbletea v2 convention - Replace rowsFrom closure with existing providerRowsFromScroll() - Extract applyHeightMode() to unify repeated height expansion logic - Extract resetProviderNav() to deduplicate provider state resets - Cache fbVisible()/providerScrollStep() to avoid redundant probe renders --- ui/model/filebrowser.go | 12 ++++++------ ui/model/keys.go | 25 ++++++++----------------- ui/model/providers.go | 34 +++++++++++++--------------------- ui/model/scroll.go | 30 +++++++----------------------- ui/model/update.go | 6 +----- ui/model/view.go | 21 +-------------------- 6 files changed, 36 insertions(+), 92 deletions(-) diff --git a/ui/model/filebrowser.go b/ui/model/filebrowser.go index a602d46..2f92285 100644 --- a/ui/model/filebrowser.go +++ b/ui/model/filebrowser.go @@ -232,16 +232,16 @@ func (m *Model) handleFileBrowserKey(msg tea.KeyPressMsg) tea.Cmd { case "pgup", "ctrl+u": if m.fileBrowser.cursor > 0 { - jump := m.fbVisible() - m.fileBrowser.cursor -= min(m.fileBrowser.cursor, jump) - m.fbMaybeAdjustScroll(m.fbVisible()) + visible := m.fbVisible() + m.fileBrowser.cursor -= min(m.fileBrowser.cursor, visible) + m.fbMaybeAdjustScroll(visible) } case "pgdown", "ctrl+d": if m.fileBrowser.cursor < len(m.fileBrowser.entries)-1 { - jump := m.fbVisible() - m.fileBrowser.cursor = min(len(m.fileBrowser.entries)-1, m.fileBrowser.cursor+jump) - m.fbMaybeAdjustScroll(m.fbVisible()) + visible := m.fbVisible() + m.fileBrowser.cursor = min(len(m.fileBrowser.entries)-1, m.fileBrowser.cursor+visible) + m.fbMaybeAdjustScroll(visible) } case "enter", "l", "right": diff --git a/ui/model/keys.go b/ui/model/keys.go index eb55315..42bd9c3 100644 --- a/ui/model/keys.go +++ b/ui/model/keys.go @@ -150,8 +150,9 @@ func (m *Model) providerMoveDown() { } func (m *Model) providerPageUp() { + step := m.providerScrollStep() if m.provCursor > 0 { - m.provCursor -= min(m.provCursor, m.providerScrollStep()) + m.provCursor -= min(m.provCursor, step) } // Top-anchor behavior: place cursor at top of viewport when paging up. m.provScroll = m.provCursor @@ -159,11 +160,12 @@ func (m *Model) providerPageUp() { } func (m *Model) providerPageDown() { + step := m.providerScrollStep() if m.provCursor < len(m.providerLists)-1 { - m.provCursor = min(len(m.providerLists)-1, m.provCursor+m.providerScrollStep()) + m.provCursor = min(len(m.providerLists)-1, m.provCursor+step) } // Bottom-anchor behavior: bias viewport so cursor lands near bottom when paging down. - m.provScroll = max(0, m.provCursor-m.providerScrollStep()+1) + m.provScroll = max(0, m.provCursor-step+1) m.providerMaybeAdjustScroll() } @@ -281,7 +283,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { return m.quit() case "up", "k": m.providerMoveUp() - case " ": + case "space": return m.togglePlayPause() case "down", "j": m.providerMoveDown() @@ -697,11 +699,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { case "v": m.vis.CycleMode() - if m.heightExpanded { - m.plVisible = m.expandedPlVisible() - } else { - m.plVisible = m.collapsedPlVisible() - } + m.applyHeightMode() m.adjustScroll() if err := m.configSaver.Save("visualizer", fmt.Sprintf("%q", m.vis.ModeName())); err != nil { m.status.Showf(statusTTLDefault, "Config save failed: %s", err) @@ -959,15 +957,8 @@ func (m *Model) updateProvSearch() { // toggleExpandPlaylist toggles the playlist panel between default and expanded height. func (m *Model) toggleExpandPlaylist() { - collapsed := m.collapsedPlVisible() - expanded := m.expandedPlVisible() - m.heightExpanded = !m.heightExpanded - if m.heightExpanded { - m.plVisible = expanded - } else { - m.plVisible = collapsed - } + m.applyHeightMode() m.adjustScroll() } diff --git a/ui/model/providers.go b/ui/model/providers.go index 5e92286..ba00291 100644 --- a/ui/model/providers.go +++ b/ui/model/providers.go @@ -9,18 +9,23 @@ import ( "cliamp/provider" ) +// resetProviderNav resets provider navigation and search state to the top. +func (m *Model) resetProviderNav() { + m.provCursor = 0 + m.provScroll = 0 + m.provLoading = true + m.provSearch.active = false + m.provSearch.query = "" + m.provSearch.results = nil + m.provSearch.cursor = 0 +} + // StartInProvider configures the model to begin in the provider browse view. // Call this from main when no CLI tracks or pending URLs were given. func (m *Model) StartInProvider() { if m.provider != nil { m.focus = focusProvider - m.provCursor = 0 - m.provScroll = 0 - m.provLoading = true - m.provSearch.active = false - m.provSearch.query = "" - m.provSearch.results = nil - m.provSearch.cursor = 0 + m.resetProviderNav() } } @@ -31,23 +36,10 @@ func (m *Model) switchProvider(idx int) tea.Cmd { } m.provPillIdx = idx m.provider = m.providers[idx].Provider - - // Reset provider list state so navigation always starts from the top. m.providerLists = nil - m.provCursor = 0 - m.provScroll = 0 - m.provLoading = true m.provSignIn = false - - // Reset provider search state to avoid stale filtered positions. - m.provSearch.active = false - m.provSearch.query = "" - m.provSearch.results = nil - m.provSearch.cursor = 0 - - // Reset catalog batching state for lazy-loaded providers. m.catalogBatch = catalogBatchState{} - + m.resetProviderNav() m.focus = focusProvider return fetchPlaylistsCmd(m.provider) } diff --git a/ui/model/scroll.go b/ui/model/scroll.go index d4bdb5a..19857a6 100644 --- a/ui/model/scroll.go +++ b/ui/model/scroll.go @@ -5,28 +5,9 @@ import ( "charm.land/lipgloss/v2" - "cliamp/playlist" "cliamp/ui" ) -// renderedLineCount returns how many rendered lines tracks[from..to) would -// take, including album separator lines between different albums. -func renderedLineCount(tracks []playlist.Track, from, to int) int { - lines := 0 - prevAlbum := "" - if from > 0 { - prevAlbum = tracks[from-1].Album - } - for i := from; i < to && i < len(tracks); i++ { - if album := tracks[i].Album; album != "" && album != prevAlbum { - lines++ // album separator - } - prevAlbum = tracks[i].Album - lines++ // track line - } - return lines -} - // measurePlVisible calculates playlist lines available for a given upper limit. func (m *Model) measurePlVisible(limit int) int { saved := m.plVisible @@ -52,10 +33,13 @@ func (m *Model) expandedPlVisible() int { return m.measurePlVisible(m.height) } -// defaultPlVisible returns the collapsed baseline height. -// Keep this stable so toggle logic can reliably compare/collapse. -func (m *Model) defaultPlVisible() int { - return m.collapsedPlVisible() +// applyHeightMode sets plVisible based on the current heightExpanded state. +func (m *Model) applyHeightMode() { + if m.heightExpanded { + m.plVisible = m.expandedPlVisible() + } else { + m.plVisible = m.collapsedPlVisible() + } } // adjustScroll ensures plCursor is visible in the playlist view. diff --git a/ui/model/update.go b/ui/model/update.go index 58d9a3a..06fda47 100644 --- a/ui/model/update.go +++ b/ui/model/update.go @@ -64,11 +64,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.vis.Rows = max(ui.DefaultVisRows, (m.height-10)*4/5) ui.PanelWidth = max(0, m.width-2*ui.PaddingH) } - if m.heightExpanded { - m.plVisible = m.expandedPlVisible() - } else { - m.plVisible = m.collapsedPlVisible() - } + m.applyHeightMode() m.adjustScroll() if m.focus == focusProvider { m.providerMaybeAdjustScroll() diff --git a/ui/model/view.go b/ui/model/view.go index 43ed44d..076c6da 100644 --- a/ui/model/view.go +++ b/ui/model/view.go @@ -531,26 +531,7 @@ func (m Model) renderProviderList() string { } if isRadio { - rowsFrom := func(start, cursor int) int { - if start < 0 || cursor < start || cursor >= len(m.providerLists) { - return 0 - } - rows := 0 - prevPrefix := "" - if start > 0 { - prevPrefix = sl.IDPrefix(m.providerLists[start-1].ID) - } - for i := start; i <= cursor; i++ { - pfx := sl.IDPrefix(m.providerLists[i].ID) - if pfx != prevPrefix { - rows++ - } - rows++ - prevPrefix = pfx - } - return rows - } - for scroll < len(m.providerLists)-1 && rowsFrom(scroll, m.provCursor) > visibleBudget { + for scroll < len(m.providerLists)-1 && m.providerRowsFromScroll(sl, scroll, m.provCursor) > visibleBudget { scroll++ } } else if m.provCursor >= scroll+visibleBudget {