151 lines
3.8 KiB
Go
151 lines
3.8 KiB
Go
package model
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"charm.land/lipgloss/v2"
|
|
|
|
"github.com/bjarneo/cliamp/ui"
|
|
)
|
|
|
|
// clampScroll keeps cursor inside [0, count) and adjusts scroll so that
|
|
// the cursor sits within the visible window of `visible` rows.
|
|
func clampScroll(cursor, scroll *int, count, visible int) {
|
|
if visible <= 0 {
|
|
return
|
|
}
|
|
if *cursor < 0 {
|
|
*cursor = 0
|
|
}
|
|
if *cursor >= count && count > 0 {
|
|
*cursor = count - 1
|
|
}
|
|
if *cursor < *scroll {
|
|
*scroll = *cursor
|
|
} else if *cursor >= *scroll+visible {
|
|
*scroll = *cursor - visible + 1
|
|
}
|
|
if *scroll+visible > count && count > 0 {
|
|
*scroll = max(0, count-visible)
|
|
}
|
|
if *scroll < 0 {
|
|
*scroll = 0
|
|
}
|
|
}
|
|
|
|
// 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 }()
|
|
|
|
// Use mainSections to get all fixed chrome plus any active transient messages.
|
|
probe := strings.Join(m.mainSections("x", true), "\n")
|
|
fixedLines := lipgloss.Height(ui.FrameStyle.Render(probe)) - 1
|
|
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)
|
|
}
|
|
|
|
// applyHeightMode sets plVisible based on the current heightExpanded state.
|
|
func (m *Model) applyHeightMode() {
|
|
if m.playlist == nil {
|
|
return
|
|
}
|
|
if m.heightExpanded {
|
|
m.plVisible = m.expandedPlVisible()
|
|
} else {
|
|
m.plVisible = m.collapsedPlVisible()
|
|
}
|
|
}
|
|
|
|
// adjustScroll ensures plCursor is visible in the playlist view.
|
|
// It accounts for album separator lines that reduce the number of
|
|
// tracks that fit in the visible window.
|
|
func (m *Model) adjustScroll() {
|
|
if m.playlist == nil {
|
|
return
|
|
}
|
|
tracks := m.playlist.Tracks()
|
|
if len(tracks) == 0 {
|
|
return
|
|
}
|
|
visible := m.effectivePlaylistVisible()
|
|
if visible <= 0 {
|
|
return
|
|
}
|
|
m.plScroll = m.playlistScroll(visible)
|
|
}
|
|
|
|
func (m Model) playlistScroll(visible int) int {
|
|
tracks := m.playlist.Tracks()
|
|
scroll := max(0, m.plScroll)
|
|
if scroll >= len(tracks) {
|
|
scroll = max(0, len(tracks)-1)
|
|
}
|
|
if m.plCursor < scroll {
|
|
return m.plCursor
|
|
}
|
|
for scroll < m.plCursor && m.albumSeparatorRows(tracks, scroll, m.plCursor, m.showAlbumHeaders) > visible {
|
|
scroll++
|
|
}
|
|
return scroll
|
|
}
|
|
|
|
func (m Model) mainFrameFixedLines(includeTransient bool) int {
|
|
if m.chromeOK {
|
|
if !includeTransient {
|
|
return m.chromeHeight
|
|
}
|
|
transientLines := len(m.footerMessages())
|
|
if m.err != nil {
|
|
transientLines++
|
|
}
|
|
return m.chromeHeight + transientLines
|
|
}
|
|
// Fallback: render and measure (only needed until first WindowSizeMsg)
|
|
content := strings.Join(m.mainSections("", includeTransient), "\n")
|
|
return lipgloss.Height(ui.FrameStyle.Render(content))
|
|
}
|
|
|
|
func (m Model) effectivePlaylistVisible() int {
|
|
available := m.height - m.mainFrameFixedLines(true)
|
|
if available <= 0 {
|
|
return 0
|
|
}
|
|
if m.plVisible <= 0 {
|
|
return 0
|
|
}
|
|
return min(m.plVisible, available)
|
|
}
|
|
|
|
// recomputeChrome renders the fixed chrome (without playlist or transients)
|
|
// and caches its height. Called when terminal width or compact mode changes.
|
|
func (m *Model) recomputeChrome() {
|
|
content := strings.Join(m.mainSections("", false), "\n")
|
|
m.chromeHeight = lipgloss.Height(ui.FrameStyle.Render(content))
|
|
m.chromeOK = true
|
|
}
|
|
|
|
// invalidateChrome marks the chrome height dirty. Until the next recompute,
|
|
// mainFrameFixedLines falls back to direct measurement.
|
|
func (m *Model) invalidateChrome() {
|
|
m.chromeOK = false
|
|
}
|
|
|
|
func (m *Model) refreshChrome() {
|
|
if m.width > 0 {
|
|
m.recomputeChrome()
|
|
return
|
|
}
|
|
m.invalidateChrome()
|
|
}
|