feat(ui): add responsive terminal layouts

This commit is contained in:
Bjarne Øverli
2026-07-20 23:30:40 +02:00
parent 853d40e479
commit 09b738eed6
21 changed files with 731 additions and 233 deletions
+14
View File
@@ -87,6 +87,20 @@ log_level = "info"
```
## Terminal Layout
cliamp adapts its playback screen to the available terminal rectangle:
| Terminal size | Layout |
| --- | --- |
| At least `80x24` | Full controls, five-row visualizer, and detailed source controls |
| At least `56x16` | Compact controls and a two-row visualizer |
| At least `40x10` | Minimal playback, list, seek bar, and help layout |
| Smaller than `40x10` | A resize message only |
`compact = true` caps the frame at 80 columns on wide terminals. It does not
change the minimum supported terminal size.
## Secrets from Environment Variables
Any string value in `config.toml` can be read from an environment variable by setting the value to `$VAR_NAME` or `${VAR_NAME}`. This keeps passwords, tokens, and client secrets out of the file itself.
+1
View File
@@ -54,6 +54,7 @@ Press `Ctrl+K` from any mode, or `?` from the player, to see all keybindings.
| `Ctrl+F` | Search — active provider's native search (Spotify, Qobuz, Navidrome, Jellyfin, Emby, Plex, NetEase, Local) or YouTube fallback. Available from playlist and provider-browser views. |
| `u` | Load URL (stream/playlist) |
| `y` | Show lyrics |
| `i` | Show track metadata (`↑`/`↓` scrolls) |
| `Ctrl+S` | Save track to ~/Music |
| `w` | Write the highlighted track to a local playlist |
| `N` | Navidrome browser |
+1
View File
@@ -774,6 +774,7 @@ user_id = "your-account-user-id"</code></pre>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Remote Control</div><p>Control a running instance from another terminal via local-socket IPC. Run with <code>--daemon</code> for headless playback driven entirely by scripts or Waybar.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Lua Plugins</div><p>Lua 5.1 sandboxed plugin system. Hook events, add visualizers, push data.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Save to Disk</div><p>Press <kbd>Ctrl+S</kbd> to save the current track to <code>~/Music</code>.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Responsive TUI</div><p>Full, compact, and minimal layouts keep playback usable in terminal splits and small SSH sessions.</p></div>
</div>
</div>
</section>
+25
View File
@@ -0,0 +1,25 @@
package ui
import (
"strings"
"github.com/charmbracelet/x/ansi"
)
// FitRect clips text to a terminal rectangle without splitting ANSI escapes or
// wide characters. It intentionally does not pad rows, so callers can compose
// compact layouts without introducing trailing whitespace.
func FitRect(text string, width, height int) string {
if width <= 0 || height <= 0 || text == "" {
return ""
}
lines := strings.Split(text, "\n")
if len(lines) > height {
lines = lines[:height]
}
for i, line := range lines {
lines[i] = ansi.Truncate(line, width, "")
}
return strings.Join(lines, "\n")
}
+33
View File
@@ -0,0 +1,33 @@
package ui
import (
"testing"
"charm.land/lipgloss/v2"
)
func TestFitRect(t *testing.T) {
tests := []struct {
name string
text string
width, height int
want string
}{
{name: "non-positive", text: "text", width: 0, height: 1, want: ""},
{name: "rows", text: "one\ntwo\nthree", width: 10, height: 2, want: "one\ntwo"},
{name: "wide", text: "ab音c", width: 4, height: 1, want: "ab音"},
{name: "ansi", text: "\x1b[31mabcdef\x1b[0m", width: 3, height: 1, want: "\x1b[31mabc\x1b[0m"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FitRect(tt.text, tt.width, tt.height)
if got != tt.want {
t.Fatalf("FitRect(%q, %d, %d) = %q, want %q", tt.text, tt.width, tt.height, got, tt.want)
}
if got != "" && lipgloss.Width(got) > tt.width {
t.Fatalf("FitRect width = %d, want <= %d", lipgloss.Width(got), tt.width)
}
})
}
}
+5 -1
View File
@@ -69,7 +69,11 @@ func (m Model) fbHelpLine() string {
// fbVisible returns the file-browser list height. The browser renders inline in
// the playlist region, so it shares the playlist's row budget.
func (m *Model) fbVisible() int {
return m.effectivePlaylistVisible()
visible := m.effectivePlaylistVisible()
if m.fileBrowser.err != "" {
return max(1, visible-1)
}
return visible
}
// fbMaybeAdjustScroll keeps the cursor visible in the current file-browser window.
+13 -2
View File
@@ -152,7 +152,7 @@ func (m Model) activeOverlay() (overlayView, bool) {
case m.showInfo:
return overlayView{
func(*Model) string { return sepHeader("Track Info") },
func(*Model) string { return helpKey("Esc", "Close") },
func(*Model) string { return helpKey("↑↓", "Scroll ") + helpKey("Esc", "Close") },
(*Model).renderInfoBody}, true
case m.lyrics.visible:
return overlayView{
@@ -255,6 +255,13 @@ func (m Model) renderQueueBody() string {
func (m Model) renderInfoBody() string {
budget := m.effectivePlaylistVisible()
lines := m.infoLines()
start := min(m.infoScroll, max(0, len(lines)-budget))
end := min(start+budget, len(lines))
return bodyLines(lines[start:end], budget)
}
func (m Model) infoLines() []string {
track, _ := m.currentPlaybackTrack()
var lines []string
@@ -277,7 +284,11 @@ func (m Model) renderInfoBody() string {
if len(lines) == 0 {
lines = append(lines, dimStyle.Render(" No track metadata available."))
}
return bodyLines(lines, budget)
return lines
}
func (m *Model) infoMaybeAdjustScroll() {
m.infoScroll = min(m.infoScroll, max(0, len(m.infoLines())-m.effectivePlaylistVisible()))
}
// — URL input —
+36 -44
View File
@@ -15,7 +15,6 @@ import (
"github.com/bjarneo/cliamp/internal/fileutil"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
"github.com/bjarneo/cliamp/ui"
)
// quit shuts down the player and signals the TUI to exit.
@@ -86,50 +85,44 @@ func (m *Model) providerMaybeAdjustScroll() {
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
if m.provScroll >= total {
m.provScroll = max(0, total-1)
}
// 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)
// Provider lists can add radio-prefix or PlaylistInfo.Section headers. Keep
// the logical cursor visible in their rendered-row viewport.
for m.provScroll < total && m.providerRowsFromScroll(m.provScroll, m.provCursor) > visible {
m.provScroll++
}
}
func (m *Model) providerRowsFromScroll(sl provider.SectionedList, scroll, cursor int) int {
func (m *Model) providerRowsFromScroll(scroll, cursor int) int {
total := len(m.providerLists)
if total == 0 || cursor < scroll || scroll < 0 || cursor >= total {
return 0
}
rows := 0
prevPrefix := ""
sl, isRadio := m.provider.(provider.SectionedList)
prevHeader := ""
if scroll > 0 {
prevPrefix = sl.IDPrefix(m.providerLists[scroll-1].ID)
if isRadio {
prevHeader = sl.IDPrefix(m.providerLists[scroll-1].ID)
} else {
prevHeader = m.providerLists[scroll-1].Section
}
}
for i := scroll; i <= cursor && i < total; i++ {
pfx := sl.IDPrefix(m.providerLists[i].ID)
if pfx != prevPrefix {
header := m.providerLists[i].Section
if isRadio {
header = sl.IDPrefix(m.providerLists[i].ID)
}
if header != "" && header != prevHeader {
rows++ // section header row
}
rows++ // item row
prevPrefix = pfx
prevHeader = header
}
return rows
}
@@ -189,6 +182,12 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
if msg.String() == "ctrl+c" {
return m.quit()
}
if m.width > 0 && m.layout.tooSmall() {
if msg.String() == "q" {
return m.quit()
}
return nil
}
if m.fullVis {
return m.handleFullVisualizerKey(msg)
}
@@ -255,6 +254,13 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
return m.quit()
case "esc", "i":
m.showInfo = false
case "up", "k":
if m.infoScroll > 0 {
m.infoScroll--
}
case "down", "j":
m.infoScroll++
m.infoMaybeAdjustScroll()
}
return nil
}
@@ -739,6 +745,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
case "i":
m.showInfo = true
m.infoScroll = 0
case "y":
m.lyrics.visible = !m.lyrics.visible
@@ -801,14 +808,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
case "V":
m.fullVis = !m.fullVis
if m.fullVis {
m.vis.Rows = m.fullVisualizerRows()
ui.PanelWidth = max(0, m.width-2*ui.PaddingH)
} else {
m.vis.Rows = ui.DefaultVisRows
m.restorePanelWidth()
}
m.refreshChrome()
m.recomputeLayout()
case "ctrl+x":
if m.focus == focusPlaylist {
@@ -849,15 +849,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
func (m *Model) exitFullVisualizer() {
m.fullVis = false
m.vis.Rows = ui.DefaultVisRows
m.restorePanelWidth()
m.refreshChrome()
}
func (m Model) fullVisualizerRows() int {
// Track info, time, two spacers, seek bar, and help occupy six rows.
const fixedRows = 6
return max(1, m.height-fixedRows-2*ui.VerticalPadding())
m.recomputeLayout()
}
func (m *Model) handleFullVisualizerKey(msg tea.KeyPressMsg) tea.Cmd {
+11 -5
View File
@@ -519,11 +519,17 @@ func navNextSort(s string, types []provider.SortType) string {
// navMaybeAdjustScroll keeps navCursor visible within the rendered list window.
func (m *Model) navMaybeAdjustScroll() {
visible := m.navVisible()
if m.navBrowser.cursor < m.navBrowser.scroll {
m.navBrowser.scroll = m.navBrowser.cursor
count := 3 // browse mode menu
switch m.navView() {
case navViewArtists:
count = len(m.navBrowser.artists)
case navViewAlbums:
count = len(m.navBrowser.albums)
case navViewTracks:
count = len(m.navBrowser.tracks)
}
if m.navBrowser.cursor >= m.navBrowser.scroll+visible {
m.navBrowser.scroll = m.navBrowser.cursor - visible + 1
if m.navBrowser.search != "" {
count = len(m.navBrowser.searchIdx)
}
clampScroll(&m.navBrowser.cursor, &m.navBrowser.scroll, count, m.navVisible())
}
+1 -1
View File
@@ -139,7 +139,7 @@ func (m *Model) handleSpotSearchResultsKey(msg tea.KeyPressMsg) tea.Cmd {
func (m *Model) spotSearchPlaylistMaybeAdjustScroll(visible int) {
count := len(m.spotSearch.playlists) + 1
clampScroll(&m.spotSearch.cursor, &m.spotSearch.scroll, count, visible)
clampScroll(&m.spotSearch.cursor, &m.spotSearch.scroll, count, max(1, visible-1))
}
// handleSpotSearchPlaylistKey handles picking a playlist to add to.
+91
View File
@@ -0,0 +1,91 @@
package model
import "github.com/bjarneo/cliamp/ui"
type layoutTier int
const (
layoutTooSmall layoutTier = iota
layoutMinimal
layoutCompact
layoutFull
)
type frameLayout struct {
tier layoutTier
frameWidth int
panelWidth int
paddingH int
paddingV int
fixedRows int
footerRows int
bodyRows int
visualizerRows int
fullVisualizerRows int
}
func (l frameLayout) tooSmall() bool {
return l.tier == layoutTooSmall
}
func (m *Model) recomputeLayout() {
width, height := m.width, m.height
if width <= 0 {
width = 80
}
if height <= 0 {
height = 24
}
frameWidth := width
if m.compact {
frameWidth = min(frameWidth, 80)
}
paddingH := min(ui.PaddingH, max(0, (frameWidth-1)/2))
paddingV := min(ui.VerticalPadding(), max(0, (height-1)/2))
layout := frameLayout{
frameWidth: frameWidth,
panelWidth: max(1, frameWidth-2*paddingH),
paddingH: paddingH,
paddingV: paddingV,
footerRows: 1,
}
switch {
case width < 40 || height < 10:
layout.tier = layoutTooSmall
case width >= 80 && height >= 24:
layout.tier = layoutFull
layout.visualizerRows = ui.DefaultVisRows
layout.fixedRows = 18
case width >= 56 && height >= 16:
layout.tier = layoutCompact
layout.visualizerRows = 2
layout.fixedRows = 11
default:
layout.tier = layoutMinimal
layout.fixedRows = 5
}
layout.fullVisualizerRows = max(1, height-6-2*paddingV)
if !layout.tooSmall() {
layout.bodyRows = max(1, height-2*paddingV-layout.fixedRows-layout.footerRows)
limit := maxPlVisible
if m.heightExpanded {
limit = maxPlExpandVisible
}
m.plVisible = min(limit, layout.bodyRows)
}
m.layout = layout
ui.FrameStyle = ui.FrameStyle.Padding(paddingV, paddingH).Width(frameWidth)
ui.PanelWidth = layout.panelWidth
if m.vis != nil {
m.vis.Cols = layout.panelWidth
if m.fullVis {
m.vis.Rows = layout.fullVisualizerRows
} else {
m.vis.Rows = layout.visualizerRows
}
}
}
+213
View File
@@ -0,0 +1,213 @@
package model
import (
"fmt"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/theme"
"github.com/bjarneo/cliamp/ui"
)
func newLayoutTestModel(width, height int) Model {
player := &playbackFakeEngine{}
pl := playlist.New()
for i := range 16 {
pl.Add(playlist.Track{
Path: fmt.Sprintf("/tmp/track-%d.mp3", i),
Title: "A very long 音楽 track title that must remain inside the terminal",
})
}
m := Model{
player: player,
playlist: pl,
vis: ui.NewVisualizer(float64(player.SampleRate())),
width: width,
height: height,
focus: focusPlaylist,
}
m.vis.Mode = ui.VisBars
m.recomputeLayout()
return m
}
func TestFrameLayoutTiers(t *testing.T) {
tests := []struct {
name string
width int
height int
wantTier layoutTier
wantVisRows int
}{
{name: "too small", width: 39, height: 9, wantTier: layoutTooSmall},
{name: "minimal", width: 40, height: 10, wantTier: layoutMinimal},
{name: "compact", width: 56, height: 16, wantTier: layoutCompact, wantVisRows: 2},
{name: "full", width: 80, height: 24, wantTier: layoutFull, wantVisRows: ui.DefaultVisRows},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := newLayoutTestModel(tt.width, tt.height)
if m.layout.tier != tt.wantTier {
t.Fatalf("layout tier = %v, want %v", m.layout.tier, tt.wantTier)
}
if tt.wantTier == layoutTooSmall {
if m.layout.bodyRows != 0 {
t.Fatalf("body rows = %d, want 0", m.layout.bodyRows)
}
return
}
if m.layout.bodyRows < 1 {
t.Fatalf("body rows = %d, want at least one", m.layout.bodyRows)
}
if m.vis.Rows != tt.wantVisRows {
t.Fatalf("visualizer rows = %d, want %d", m.vis.Rows, tt.wantVisRows)
}
if m.vis.Cols != m.layout.panelWidth {
t.Fatalf("visualizer columns = %d, want %d", m.vis.Cols, m.layout.panelWidth)
}
})
}
}
func TestResponsiveViewsFitTerminal(t *testing.T) {
for _, size := range []struct{ width, height int }{
{39, 9},
{40, 10},
{56, 16},
{80, 20},
{80, 24},
{120, 40},
} {
t.Run(fmt.Sprintf("%dx%d", size.width, size.height), func(t *testing.T) {
m := newLayoutTestModel(size.width, size.height)
m.status.text = "a status message\nthat must not create another row"
out := m.View().Content
if got := lipgloss.Height(out); got > size.height {
t.Fatalf("view height = %d, want <= %d\n%s", got, size.height, out)
}
for _, line := range strings.Split(out, "\n") {
if got := lipgloss.Width(line); got > size.width {
t.Fatalf("line width = %d, want <= %d: %q", got, size.width, line)
}
}
})
}
}
func TestResizeClampsActiveOverlayCursor(t *testing.T) {
m := newLayoutTestModel(120, 40)
m.themePicker.visible = true
m.themes = make([]theme.Theme, 40)
m.themePicker.cursor = 40
m.themePicker.scroll = 35
updated, _ := m.Update(tea.WindowSizeMsg{Width: 56, Height: 16})
m = updated.(Model)
if m.themePicker.cursor >= len(m.themes)+1 {
t.Fatalf("theme cursor = %d, want within %d entries", m.themePicker.cursor, len(m.themes)+1)
}
if m.themePicker.cursor < m.themePicker.scroll || m.themePicker.cursor >= m.themePicker.scroll+m.themePickerVisible() {
t.Fatalf("theme cursor %d outside viewport [%d,%d)", m.themePicker.cursor, m.themePicker.scroll, m.themePicker.scroll+m.themePickerVisible())
}
}
func TestLayoutClampsConfiguredPadding(t *testing.T) {
previousStyle := ui.FrameStyle
previousPanelWidth := ui.PanelWidth
previousPaddingH := ui.PaddingH
previousPaddingV := ui.VerticalPadding()
ui.SetPadding(10, 5)
t.Cleanup(func() {
ui.SetPadding(previousPaddingH, previousPaddingV)
ui.FrameStyle = previousStyle
ui.PanelWidth = previousPanelWidth
})
m := newLayoutTestModel(40, 10)
if m.layout.panelWidth <= 0 {
t.Fatalf("panel width = %d, want positive", m.layout.panelWidth)
}
if got := m.View().Content; lipgloss.Height(got) > 10 {
t.Fatalf("view height = %d, want <= 10", lipgloss.Height(got))
}
}
func TestTooSmallLayoutBlocksHiddenMutations(t *testing.T) {
m := newLayoutTestModel(39, 9)
before := m.playlist.Len()
m.handleKey(tea.KeyPressMsg{Text: "x"})
if got := m.playlist.Len(); got != before {
t.Fatalf("playlist length = %d after hidden remove, want %d", got, before)
}
}
func TestTrackInfoScrollsWithinBodyBudget(t *testing.T) {
m := newLayoutTestModel(40, 10)
track := m.playlist.Tracks()[0]
track.Artist = "Artist"
track.Album = "Album"
track.Genre = "Genre"
track.Year = 2026
track.TrackNumber = 1
m.playlist.SetTrack(0, track)
m.showInfo = true
m.handleKey(tea.KeyPressMsg{Text: "j"})
if m.infoScroll == 0 {
t.Fatal("info scroll = 0 after down, want a later metadata row")
}
if got := m.renderInfoBody(); !strings.Contains(got, "Artist") {
t.Fatalf("track info body = %q, want scrolled metadata", got)
}
}
func TestInlineOverlaysFitResponsiveTerminal(t *testing.T) {
overlays := []struct {
name string
set func(*Model)
}{
{name: "keymap", set: func(m *Model) { m.keymap.visible = true; m.keymap.entries = m.buildKeymapEntries() }},
{name: "theme", set: func(m *Model) { m.themePicker.visible = true }},
{name: "visualizer", set: func(m *Model) { m.visPicker.visible = true; m.visPicker.modes = m.vis.AllModeNames() }},
{name: "device", set: func(m *Model) { m.devicePicker.visible = true }},
{name: "playlist picker", set: func(m *Model) { m.plPicker.visible = true }},
{name: "file browser", set: func(m *Model) { m.fileBrowser.visible = true }},
{name: "provider search", set: func(m *Model) { m.spotSearch.visible = true }},
{name: "navigation", set: func(m *Model) { m.navBrowser.visible = true }},
{name: "playlist manager", set: func(m *Model) { m.plManager.visible = true }},
{name: "queue", set: func(m *Model) { m.queue.visible = true }},
{name: "info", set: func(m *Model) { m.showInfo = true }},
{name: "lyrics", set: func(m *Model) { m.lyrics.visible = true }},
{name: "jump", set: func(m *Model) { m.jumping = true }},
{name: "url", set: func(m *Model) { m.urlInputting = true }},
{name: "search", set: func(m *Model) { m.search.active = true }},
{name: "online search", set: func(m *Model) { m.netSearch.active = true }},
}
for _, size := range []struct{ width, height int }{{40, 10}, {56, 16}, {80, 24}} {
for _, overlay := range overlays {
t.Run(fmt.Sprintf("%s_%dx%d", overlay.name, size.width, size.height), func(t *testing.T) {
m := newLayoutTestModel(size.width, size.height)
overlay.set(&m)
assertViewFits(t, m.View().Content, size.width, size.height)
})
}
}
}
func assertViewFits(t *testing.T, view string, width, height int) {
t.Helper()
if got := lipgloss.Height(view); got > height {
t.Fatalf("view height = %d, want <= %d\n%s", got, height, view)
}
for _, line := range strings.Split(view, "\n") {
if got := lipgloss.Width(line); got > width {
t.Fatalf("line width = %d, want <= %d: %q", got, width, line)
}
}
}
+3 -8
View File
@@ -199,6 +199,7 @@ type Model struct {
quitting bool
width int
height int
layout frameLayout
// Provider state
provider playlist.Provider
@@ -314,7 +315,8 @@ type Model struct {
themeIdx int
// Track info overlay (metadata details)
showInfo bool
showInfo bool
infoScroll int
showAlbumHeaders bool
headerManual bool
@@ -340,13 +342,6 @@ type Model struct {
cachedDur time.Duration
lastTickAt time.Time // wall time of previous tickMsg; used for tick delta
// Cached height of the fixed chrome (title, track info, time, seek bar,
// controls, provider pill, playlist header, help, bottom status, no
// transient footer). Reused to avoid rendering all chrome sections twice
// per View() call. The measurement in effectivePlaylistVisible() uses
// this cache instead of a full render pass.
chromeHeight int
chromeOK bool
}
func (m Model) activeScreen() topLevelScreen {
+3
View File
@@ -52,6 +52,9 @@ func (m *Model) plPickerCount() int {
}
func (m *Model) plPickerVisible() int {
if m.plPicker.screen == plPickerChoose {
return max(1, m.effectivePlaylistVisible()-1)
}
return m.effectivePlaylistVisible()
}
+73 -65
View File
@@ -1,13 +1,5 @@
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) {
@@ -33,38 +25,9 @@ func clampScroll(cursor, scroll *int, count, visible int) {
}
}
// 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()
}
m.recomputeLayout()
}
// adjustScroll ensures plCursor is visible in the playlist view.
@@ -101,50 +64,95 @@ func (m Model) playlistScroll(visible int) int {
}
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
if m.layout.frameWidth == 0 {
m.recomputeLayout()
}
// Fallback: render and measure (only needed until first WindowSizeMsg)
content := strings.Join(m.mainSections("", includeTransient), "\n")
return lipgloss.Height(ui.FrameStyle.Render(content))
fixed := 2*m.layout.paddingV + m.layout.fixedRows
if includeTransient {
fixed += m.layout.footerRows
}
return fixed
}
func (m Model) effectivePlaylistVisible() int {
available := m.height - m.mainFrameFixedLines(true)
if available <= 0 {
if m.layout.frameWidth == 0 {
if m.plVisible > 0 {
return m.plVisible
}
return 0
}
if m.plVisible <= 0 {
if m.layout.tooSmall() || m.layout.bodyRows <= 0 {
return 0
}
return min(m.plVisible, available)
return min(m.plVisible, m.layout.bodyRows)
}
// recomputeChrome renders the fixed chrome (without playlist or transients)
// and caches its height. Called when terminal width or compact mode changes.
// recomputeChrome preserves the existing layout-refresh seam for callers that
// change an overlay or visualizer mode.
func (m *Model) recomputeChrome() {
content := strings.Join(m.mainSections("", false), "\n")
m.chromeHeight = lipgloss.Height(ui.FrameStyle.Render(content))
m.chromeOK = true
m.recomputeLayout()
}
// invalidateChrome marks the chrome height dirty. Until the next recompute,
// mainFrameFixedLines falls back to direct measurement.
func (m *Model) invalidateChrome() {
m.chromeOK = false
m.recomputeLayout()
}
func (m *Model) refreshChrome() {
if m.width > 0 {
m.recomputeChrome()
m.recomputeLayout()
}
func (m *Model) clampActiveScrollState() {
if m.layout.tooSmall() {
return
}
m.invalidateChrome()
if m.provSearch.active {
m.provSearchMaybeAdjustScroll()
return
}
switch m.activeScreen() {
case screenKeymap:
m.keymapMaybeAdjustScroll(m.keymapVisible())
case screenThemePicker:
m.themePickerMaybeAdjustScroll(m.themePickerVisible())
case screenVisPicker:
m.visPickerMaybeAdjustScroll(m.visPickerVisible())
case screenDevicePicker:
clampScroll(&m.devicePicker.cursor, &m.devicePicker.scroll, len(m.devicePicker.devices), m.devicePickerVisible())
case screenPlaylistPicker:
m.plPickerMaybeAdjustScroll(m.plPickerVisible())
case screenFileBrowser:
m.fbMaybeAdjustScroll(m.fbVisible())
case screenNavBrowser:
m.navMaybeAdjustScroll()
case screenPlaylistManager:
if m.plManager.screen == plMgrScreenList {
m.plMgrListMaybeAdjustScroll(m.plMgrListVisible())
} else if m.plManager.screen == plMgrScreenTracks {
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
}
case screenSpotSearch:
if m.spotSearch.screen == spotSearchResults {
m.spotSearchResultsMaybeAdjustScroll(m.spotSearchResultsVisible())
} else if m.spotSearch.screen == spotSearchPlaylist {
m.spotSearchPlaylistMaybeAdjustScroll(m.spotSearchPlaylistVisible())
}
case screenQueue:
m.queueMaybeAdjustScroll(m.queueVisible())
case screenInfo:
m.infoMaybeAdjustScroll()
case screenSearch:
m.searchMaybeAdjustScroll(m.searchVisible())
case screenNetSearch:
if m.netSearch.screen == netSearchResults {
m.netSearchResultsMaybeAdjustScroll(m.netSearchResultsVisible())
}
case screenLyrics:
m.lyrics.scroll = min(m.lyrics.scroll, max(0, len(m.lyrics.lines)-m.effectivePlaylistVisible()))
default:
if m.focus == focusProvider {
m.providerMaybeAdjustScroll()
} else {
m.adjustScroll()
}
}
}
+3 -38
View File
@@ -61,39 +61,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
// Dynamic frame width: use full terminal width, or cap at 80 in compact mode.
frameW := msg.Width
if m.compact {
frameW = min(frameW, 80)
}
ui.FrameStyle = ui.FrameStyle.Width(frameW)
m.restorePanelWidth()
if m.fullVis {
m.vis.Rows = m.fullVisualizerRows()
ui.PanelWidth = max(0, m.width-2*ui.PaddingH)
}
m.recomputeChrome()
m.applyHeightMode()
m.adjustScroll()
if m.focus == focusProvider {
m.providerMaybeAdjustScroll()
}
if m.fileBrowser.visible {
m.fbMaybeAdjustScroll(m.fbVisible())
}
if m.plPicker.visible {
m.plPickerMaybeAdjustScroll(m.plPickerVisible())
}
if m.keymap.visible {
m.keymapMaybeAdjustScroll(m.keymapVisible())
}
if m.plManager.visible {
if m.plManager.screen == plMgrScreenList {
m.plMgrListMaybeAdjustScroll(m.plMgrListVisible())
} else if m.plManager.screen == plMgrScreenTracks {
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
}
}
m.recomputeLayout()
m.clampActiveScrollState()
return m, nil
case seekTickMsg:
@@ -1233,9 +1202,5 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// restorePanelWidth resets PanelWidth to the correct value based on compact mode.
func (m *Model) restorePanelWidth() {
frameW := m.width
if m.compact {
frameW = min(frameW, 80)
}
ui.PanelWidth = max(0, frameW-2*ui.PaddingH)
m.recomputeLayout()
}
+95 -43
View File
@@ -116,6 +116,13 @@ func (m Model) View() tea.View {
if m.quitting {
return tea.NewView("")
}
m.recomputeLayout()
if m.layout.tooSmall() {
content := fmt.Sprintf("Terminal too small. Resize to at least 40x10 (current: %dx%d).", m.width, m.height)
view := tea.NewView(ui.FitRect(content, max(1, m.width), max(1, m.height)))
view.AltScreen = true
return view
}
screen := m.activeScreen()
if !screen.hidesVisualizer() {
@@ -131,12 +138,14 @@ func (m Model) View() tea.View {
// with its header/help supplied by renderPlaylistHeader / renderHelp, so
// the now-playing + visualizer chrome stays live above and the layout
// height never shifts when an overlay opens.
content = strings.Join(m.mainSections(m.renderMainBody(), true), "\n")
body := ui.FitRect(m.renderMainBody(), m.layout.panelWidth, m.layout.bodyRows)
content = strings.Join(m.mainSections(body, true), "\n")
}
// Every screen now renders within the main frame, so frame and center
// uniformly.
rendered := m.centerFrame(ui.FrameStyle.Render(content))
rendered = ui.FitRect(rendered, m.layout.frameWidth, max(1, m.height))
view := tea.NewView(rendered)
view.AltScreen = true
@@ -152,67 +161,110 @@ func trimTrailingEmpty(sections []string) []string {
}
func (m Model) mainSections(playlist string, includeTransient bool) []string {
sections := []string{
// Now playing
m.renderTitle(),
m.renderTrackInfo(),
m.renderTimeStatus(),
"",
// ui.Visualizer
m.renderSpectrum(),
m.renderSeekBar(),
"",
// Controls
m.renderControls(),
m.renderProviderPill(),
"",
// Playlist
m.renderPlaylistHeader(),
var sections []string
switch m.layout.tier {
case layoutCompact:
sections = []string{
m.renderTitle(),
m.renderTrackInfo(),
m.renderTimeStatus(),
m.renderSpectrum(),
m.renderSeekBar(),
m.renderCompactControls(),
m.renderCompactSource(),
m.renderPlaylistHeader(),
}
case layoutMinimal:
sections = []string{
m.renderTrackInfo(),
m.renderTimeStatus(),
m.renderSeekBar(),
m.renderPlaylistHeader(),
}
default:
sections = []string{
m.renderTitle(),
m.renderTrackInfo(),
m.renderTimeStatus(),
"",
m.renderSpectrum(),
m.renderSeekBar(),
"",
m.renderControls(),
}
if source := m.renderProviderPill(); source != "" {
sections = append(sections, source)
}
sections = append(sections, "", m.renderPlaylistHeader())
}
if playlist != "" {
sections = append(sections, playlist)
}
sections = append(sections,
"",
// Help
m.renderHelp(),
m.renderBottomStatus(),
)
sections = append(sections, "", m.renderTierHelp(), m.renderBottomStatus())
if includeTransient {
if m.err != nil {
sections = append(sections, errorStyle.Render(fmt.Sprintf("ERR: %s", m.err)))
if line := m.renderTransient(); line != "" {
sections = append(sections, line)
}
sections = append(sections, m.footerMessages()...)
}
return trimTrailingEmpty(sections)
}
func (m Model) footerMessages() []string {
var lines []string
if text := m.save.activityText(); text != "" {
lines = append(lines, statusStyle.Render(text))
func (m Model) renderTierHelp() string {
if m.layout.tier != layoutMinimal {
return m.renderHelp()
}
if m.status.text != "" {
lines = append(lines, statusStyle.Render(m.status.text))
if ov, ok := m.activeOverlay(); ok {
return fitHelpLine(ov.help(&m))
}
for _, l := range m.logLines {
lines = append(lines, dimStyle.Render(l.text))
}
return lines
return fitHelpLine(helpKey("Spc", "Play ") + helpKey("?", "Keys"))
}
// centerFrame centers a pre-rendered frame in the terminal using plain string
// padding instead of allocating a new lipgloss.Style every render.
func (m Model) renderTransient() string {
if m.err != nil {
return ui.FitRect(errorStyle.Render(fmt.Sprintf("ERR: %s", m.err)), m.layout.panelWidth, 1)
}
if text := m.save.activityText(); text != "" {
return ui.FitRect(statusStyle.Render(text), m.layout.panelWidth, 1)
}
if m.status.text != "" {
return ui.FitRect(statusStyle.Render(m.status.text), m.layout.panelWidth, 1)
}
if n := len(m.logLines); n > 0 {
return ui.FitRect(dimStyle.Render(m.logLines[n-1].text), m.layout.panelWidth, 1)
}
return ""
}
func (m Model) renderCompactControls() string {
mono := ""
if m.player.Mono() {
mono = " [M]"
}
return labelStyle.Render("EQ ") + activeToggle.Render("["+m.EQPresetName()+"]") +
" " + labelStyle.Render("VOL ") + fmt.Sprintf("%+.0fdB", m.player.Volume()) + mono
}
func (m Model) renderCompactSource() string {
if len(m.providers) <= 1 {
return ""
}
name := "Unknown"
if m.provider != nil {
name = m.provider.Name()
}
return labelStyle.Render("SRC ") + trackStyle.Render("["+name+"]") + dimStyle.Render(fmt.Sprintf(" %d/%d", m.provPillIdx+1, len(m.providers)))
}
// centerFrame horizontally centers a pre-rendered frame without wasting rows
// that can instead hold playlist content.
func (m Model) centerFrame(frame string) string {
frameW := lipgloss.Width(frame)
frameH := lipgloss.Height(frame)
padLeft := max(0, (m.width-frameW)/2)
padTop := max(0, (m.height-frameH)/2)
if padLeft == 0 {
return strings.Repeat("\n", padTop) + frame
return frame
}
// Indent every line by padLeft spaces.
prefix := strings.Repeat(" ", padLeft)
@@ -220,7 +272,7 @@ func (m Model) centerFrame(frame string) string {
for i, l := range lines {
lines[i] = prefix + l
}
return strings.Repeat("\n", padTop) + strings.Join(lines, "\n")
return strings.Join(lines, "\n")
}
func (m Model) renderTitle() string {
@@ -598,7 +650,7 @@ func (m Model) renderProviderList() string {
})
if isRadio {
for scroll < len(m.providerLists)-1 && m.providerRowsFromScroll(sl, scroll, m.provCursor) > visibleBudget {
for scroll < len(m.providerLists)-1 && m.providerRowsFromScroll(scroll, m.provCursor) > visibleBudget {
scroll++
}
} else if m.provCursor >= scroll+visibleBudget {
+7 -13
View File
@@ -5,7 +5,6 @@ import (
"iter"
"strings"
"time"
"unicode/utf8"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
@@ -72,8 +71,8 @@ func formatTrackRow(num int, name string, secs int) string {
const prefixOverhead = 4 // leaves room for " " / "> " caller prefix
dur := formatTrackTime(secs)
numStr := fmt.Sprintf("%d. ", num)
numLen := utf8.RuneCountInString(numStr)
durLen := utf8.RuneCountInString(dur)
numLen := lipgloss.Width(numStr)
durLen := lipgloss.Width(dur)
titleBudget := ui.PanelWidth - prefixOverhead - numLen
if dur != "" {
@@ -87,28 +86,23 @@ func formatTrackRow(num int, name string, secs int) string {
return numStr + title
}
pad := ui.PanelWidth - prefixOverhead - durLen - numLen - utf8.RuneCountInString(title)
pad := ui.PanelWidth - prefixOverhead - durLen - numLen - lipgloss.Width(title)
if pad < 1 {
pad = 1
}
return numStr + title + strings.Repeat(" ", pad) + dur
}
// truncate shortens s to maxW runes, appending "…" if truncated.
// Uses RuneCountInString first to avoid rune slice allocation in the common
// case where the string is already short enough.
// truncate shortens s to maxW terminal cells, preserving ANSI escapes and
// avoiding splits inside wide characters.
func truncate(s string, maxW int) string {
if maxW <= 0 {
return ""
}
if utf8.RuneCountInString(s) <= maxW {
if lipgloss.Width(s) <= maxW {
return s
}
if maxW == 1 {
return "…"
}
r := []rune(s)
return string(r[:maxW-1]) + "…"
return ansi.Truncate(s, maxW, "…")
}
// cursorLine renders a list item with "> " prefix when active, " " otherwise.
+6 -4
View File
@@ -49,9 +49,10 @@ func TestMainViewShrinksPlaylistForFooterMessages(t *testing.T) {
m.save.startDownload()
m.status.Show("Saved", statusTTLDefault)
m.height = m.mainFrameFixedLines(true) + 1
m.recomputeLayout()
if got := m.effectivePlaylistVisible(); got != 1 {
t.Fatalf("effectivePlaylistVisible() = %d, want 1 with one row left after footer lines", got)
if got := m.effectivePlaylistVisible(); got != 8 {
t.Fatalf("effectivePlaylistVisible() = %d, want 8 in compact layout", got)
}
if got := lipgloss.Height(m.View().Content); got > m.height {
t.Fatalf("View() height = %d, want <= %d after footer lines shrink playlist", got, m.height)
@@ -88,9 +89,10 @@ func TestRenderPlaylistKeepsCursorVisibleWhenFooterShrinksBudget(t *testing.T) {
m.save.startDownload()
m.status.Show("Saved", statusTTLDefault)
m.height = m.mainFrameFixedLines(true) + 2
m.recomputeLayout()
if got := m.effectivePlaylistVisible(); got != 2 {
t.Fatalf("effectivePlaylistVisible() = %d, want 2 with footer-shrunk playlist", got)
if got := m.effectivePlaylistVisible(); got != 9 {
t.Fatalf("effectivePlaylistVisible() = %d, want 9 in compact layout", got)
}
out := m.renderPlaylist()
+59 -9
View File
@@ -80,16 +80,21 @@ var brailleBit = [4][2]rune{
{0x40, 0x80}, // row 3
}
// visBandWidth returns the character width for band b so that all bands plus
// 1-char gaps exactly fill PanelWidth. The remainder is distributed across the
// first few bands.
// visBandWidth returns the character width for band b. At narrow widths only
// the leading visible bands receive columns; final frame fitting clips the
// legacy inter-band gaps emitted by older renderers.
func visBandWidth(totalBands, b int) int {
const gap = 1
if totalBands <= 0 {
if totalBands <= 0 || b < 0 || b >= totalBands || PanelWidth <= 0 {
return 0
}
base := (PanelWidth - (totalBands-1)*gap) / totalBands
extra := (PanelWidth - (totalBands-1)*gap) % totalBands
visibleBands := min(totalBands, PanelWidth)
if b >= visibleBands {
return 0
}
gapCount := min(visibleBands-1, max(0, PanelWidth-visibleBands))
bandCols := PanelWidth - gapCount
base := bandCols / visibleBands
extra := bandCols % visibleBands
if b < extra {
return base + 1
}
@@ -390,6 +395,7 @@ type Visualizer struct {
lastAnalyzeAt time.Time // wall clock of the last FFT analysis
sr float64
Mode VisMode
Cols int // display width in terminal cells
Rows int // display height in terminal rows (default 5)
waveBuf []float64 // raw samples for wave mode
waveYBuf []int // reusable y-position buffer for wave rendering
@@ -748,11 +754,22 @@ func (v *Visualizer) Analyze(samples []float64, spec VisAnalysisSpec) []float64
// Render dispatches to the active visualizer mode.
func (v *Visualizer) Render() string {
if v == nil || v.Mode == VisNone || v.Rows <= 0 {
return ""
}
cols := v.columns()
if cols <= 0 {
return ""
}
previousWidth := PanelWidth
PanelWidth = cols
defer func() { PanelWidth = previousWidth }()
driver := v.syncDriverMode()
if driver == nil {
return ""
}
return driver.Render(v)
return fitVisualizerFrame(driver.Render(v), cols, v.Rows)
}
func (v *Visualizer) RequestRefresh() {
@@ -834,6 +851,17 @@ func (v *Visualizer) TickInterval(ctx VisTickContext) time.Duration {
}
func (v *Visualizer) Tick(ctx VisTickContext) {
if v == nil || v.Rows <= 0 {
return
}
cols := v.columns()
if cols <= 0 {
return
}
previousWidth := PanelWidth
PanelWidth = cols
defer func() { PanelWidth = previousWidth }()
driver := v.syncDriverMode()
if driver == nil {
return
@@ -886,7 +914,29 @@ func (d *luaModeDriver) Render(v *Visualizer) string {
if v == nil || d.index < 0 || d.index >= len(v.luaVisNames) || v.luaRender == nil {
return ""
}
return v.luaRender(v.luaVisNames[d.index], luaBands(v.bands), v.Rows, PanelWidth, v.frame)
return v.luaRender(v.luaVisNames[d.index], luaBands(v.bands), v.Rows, v.columns(), v.frame)
}
func (v *Visualizer) columns() int {
if v != nil && v.Cols > 0 {
return v.Cols
}
return PanelWidth
}
func fitVisualizerFrame(frame string, cols, rows int) string {
if cols <= 0 || rows <= 0 {
return ""
}
lines := strings.Split(FitRect(frame, cols, rows), "\n")
for len(lines) < rows {
lines = append(lines, "")
}
for i, line := range lines {
lines[i] = line + strings.Repeat(" ", max(0, cols-lipgloss.Width(line)))
}
return strings.Join(lines, "\n")
}
func (d *luaModeDriver) Tick(v *Visualizer, ctx VisTickContext) {
+38
View File
@@ -0,0 +1,38 @@
package ui
import (
"strings"
"testing"
"charm.land/lipgloss/v2"
)
func TestVisualizerFitsTinyRectangles(t *testing.T) {
for mode := VisMode(0); mode < VisCount; mode++ {
t.Run(visModes[mode].name, func(t *testing.T) {
for _, rect := range []struct{ cols, rows int }{{0, 0}, {1, 1}, {8, 2}} {
v := NewVisualizer(44100)
v.Mode = mode
v.Cols = rect.cols
v.Rows = rect.rows
v.Tick(VisTickContext{})
got := v.Render()
if rect.cols == 0 || rect.rows == 0 || mode == VisNone {
if got != "" {
t.Fatalf("%dx%d render = %q, want empty", rect.cols, rect.rows, got)
}
continue
}
lines := strings.Split(got, "\n")
if len(lines) != rect.rows {
t.Fatalf("%dx%d line count = %d, want %d: %q", rect.cols, rect.rows, len(lines), rect.rows, got)
}
for _, line := range lines {
if width := lipgloss.Width(line); width != rect.cols {
t.Fatalf("%dx%d line width = %d, want %d: %q", rect.cols, rect.rows, width, rect.cols, line)
}
}
}
})
}
}