feat(ui): add responsive terminal layouts
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user