Skip to content

Commit b52cbff

Browse files
Kenth Fagerlundclaude
andcommitted
fix(lyrics): make L work at any width and surface it in the status bar
Below 80 columns the lyrics drawer declined to open, because the station list will not render under 28 columns beside an 18 column sidebar. The only feedback was one dim status-bar line, so pressing L on a narrow terminal looked like a dead key. The sheet now has two surfaces. At 80 columns or wider it stays a drawer beside the station list; below that it takes over the content area as a full-width overlay. Resizing moves an open sheet between the two instead of making it vanish, and the width refusal is gone entirely. The status bar also carries [L] Lyrics and [A] Art on every station tab. Previously the keys appeared only in the which-key overlay, so nothing in the default view hinted the feature existed. The legend is clipped to the terminal width, so its order is now a priority list and the two new entries sit near the front; y/Yank and a/Add move to the widest tier only. Verified against the real binary in a pty at 70 columns: L opens the overlay and the legend shows both keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 45d9dbf commit b52cbff

7 files changed

Lines changed: 167 additions & 54 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,8 @@ Modern terminals grew real graphics capabilities, so `halpradio` uses them. Pres
331331
- **Auto-Scrolling Karaoke View**: When timestamped `.lrc` data exists, the drawer highlights the line being sung, fades the surrounding lines, and draws a progress gauge across the active line.
332332
- **Manual Scroll For Plain Text**: Unsynced lyrics render as a formatted sheet you scroll with `j` / `k`.
333333
- **Sync Nudge**: Internet radio exposes no seek position, so the lyric clock starts when the station announces a new title. Press `,` and `.` to shift the sync in 0.5 second steps when a station announces late or early.
334-
- **Never Blocks The UI**: Every lookup runs as a Bubble Tea command off the update loop, so the TUI stays responsive on slow connections, and the drawer takes its own columns rather than overlapping the station list.
334+
- **Never Blocks The UI**: Every lookup runs as a Bubble Tea command off the update loop, so the TUI stays responsive on slow connections.
335+
- **Fits Any Terminal**: At 80 columns or wider the drawer takes its own columns rather than overlapping the station list; below that the sheet becomes a full-width overlay, and resizing moves it between the two without closing it.
335336
- **Disk & Memory Cache**: Sheets are memoised in RAM and cached under `~/.cache/halpradio/lyrics/`, and stations with no match are negative-cached so the APIs are not hammered every track.
336337

337338
### 🖼️ Multi-Protocol Album Art (`A` key)

docs/LYRICS_AND_ART.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,13 @@ JSON sidecar holding the provider, origin URL and fetch time.
149149
└───────────────────────────────┴────────────────────────────────┘
150150
```
151151

152-
- The drawer **takes its own columns** rather than overlapping the station
153-
list, so the list keeps its own layout and selection.
154-
- It needs an 80 column terminal, because the station list will not render
155-
below 28 columns beside an 18 column sidebar. Narrower terminals get a status
156-
message instead of a broken frame.
152+
- On an 80 column terminal or wider the drawer **takes its own columns** rather
153+
than overlapping the station list, so the list keeps its layout and
154+
selection.
155+
- Below that the sheet takes over the content area as a full-width overlay,
156+
because the station list will not render under 28 columns beside an 18 column
157+
sidebar. `L` therefore always shows something at any width, and resizing
158+
moves the sheet between the two surfaces without closing it.
157159
- Every lookup runs as a Bubble Tea command off the update loop, so a slow
158160
provider never blocks the keyboard. Results that arrive after the track has
159161
changed are dropped by comparing the track key.
@@ -174,6 +176,9 @@ JSON sidecar holding the provider, origin URL and fetch time.
174176
| `h` | Return focus to the station list, leaving the drawer open |
175177
| `Esc` | Close the drawer |
176178

179+
The status bar carries `[L] Lyrics` and `[A] Art` on every station tab, so the
180+
feature is discoverable without opening the which-key overlay.
181+
177182
### Configuration
178183

179184
```yaml

pkg/ui/components/statusbar.go

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -162,49 +162,66 @@ func RenderStatusBar(searchQuery string, message string, activeTab int, width in
162162
}
163163
}
164164
} else {
165-
// Standard tabs legend
166-
if width >= 95 {
165+
// Standard tabs legend. The bar is clipped to the terminal width, so
166+
// the order doubles as a priority list: anything that has to survive
167+
// on a narrow terminal belongs near the front.
168+
if width >= 118 {
167169
items = []struct {
168170
key string
169171
desc string
170172
}{
171173
{"j/k", "Nav"},
172-
{"Space", "Play/Pause"},
174+
{"Space", "Play"},
175+
{"L", "Lyrics"},
176+
{"A", "Art"},
173177
{"I", "Identify"},
174-
{"z", "Timer/Pomo"},
178+
{"z", "Timer"},
175179
{"f", "Fav"},
176-
{"y", "Yank"},
177180
{"+/-", "Vol"},
178181
{"/", "Search"},
179-
{"a", "Add"},
180-
{"?", "WhichKey"},
182+
{"?", "Help"},
181183
{"q", "Quit"},
182184
}
183-
} else if width >= 65 {
185+
} else if width >= 95 {
184186
items = []struct {
185187
key string
186188
desc string
187189
}{
188190
{"j/k", "Nav"},
189191
{"Space", "Play"},
192+
{"L", "Lyrics"},
193+
{"A", "Art"},
190194
{"z", "Timer"},
191-
{"f", "Fav"},
192195
{"+/-", "Vol"},
193196
{"/", "Search"},
194197
{"?", "Help"},
195198
{"q", "Quit"},
196199
}
197-
} else {
200+
} else if width >= 65 {
198201
items = []struct {
199202
key string
200203
desc string
201204
}{
202205
{"j/k", "Nav"},
203206
{"Space", "Play"},
207+
{"L", "Lyrics"},
208+
{"A", "Art"},
209+
{"f", "Fav"},
204210
{"/", "Search"},
205211
{"?", "Help"},
206212
{"q", "Quit"},
207213
}
214+
} else {
215+
items = []struct {
216+
key string
217+
desc string
218+
}{
219+
{"j/k", "Nav"},
220+
{"Space", "Play"},
221+
{"L", "Lyrics"},
222+
{"?", "Help"},
223+
{"q", "Quit"},
224+
}
208225
}
209226
}
210227

pkg/ui/components/statusbar_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"strings"
55
"testing"
66

7+
"github.com/charmbracelet/lipgloss"
78
"github.com/halpworld/halpradio/pkg/theme"
89
)
910

@@ -50,3 +51,28 @@ func TestRenderStatusBar(t *testing.T) {
5051
t.Errorf("Expected Sweep/Band keys in Tuner statusbar, got: %s", tunerOut)
5152
}
5253
}
54+
55+
func TestRenderStatusBar_SurfacesLyricsAndArtKeys(t *testing.T) {
56+
th := theme.GetTheme("tokyonight")
57+
for _, width := range []int{65, 80, 95, 118, 140, 200} {
58+
out := RenderStatusBar("", "", 1, width, th)
59+
if !strings.Contains(out, "[L]") {
60+
t.Errorf("width %d: expected the lyrics key in the legend, got:\n%s", width, out)
61+
}
62+
if !strings.Contains(out, "[A]") {
63+
t.Errorf("width %d: expected the album art key in the legend, got:\n%s", width, out)
64+
}
65+
if got := lipgloss.Width(out); got > width {
66+
t.Errorf("width %d: legend rendered %d columns", width, got)
67+
}
68+
}
69+
70+
// The narrowest tier keeps lyrics but drops art, and must still fit.
71+
narrow := RenderStatusBar("", "", 1, 50, th)
72+
if !strings.Contains(narrow, "[L]") {
73+
t.Errorf("expected the lyrics key to survive a 50 column legend, got:\n%s", narrow)
74+
}
75+
if got := lipgloss.Width(narrow); got > 50 {
76+
t.Errorf("narrow legend rendered %d columns, want at most 50", got)
77+
}
78+
}

pkg/ui/nowplaying.go

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -218,11 +218,13 @@ func (m Model) fetchCoverCmd(stationID, key string) tea.Cmd {
218218

219219
// LyricsDrawerMinWidth is the narrowest terminal that can host the drawer
220220
// alongside the station list, which itself will not render below 28 columns
221-
// next to an 18 column sidebar.
221+
// next to an 18 column sidebar. Below this the sheet takes over the content
222+
// area instead, so L always shows something.
222223
const LyricsDrawerMinWidth = 80
223224

224225
// LyricsDrawerWidth returns how many columns the lyrics drawer occupies for a
225-
// given terminal width, or 0 when the terminal is too narrow to host it.
226+
// given terminal width, or 0 when the terminal cannot host it beside the
227+
// station list.
226228
func LyricsDrawerWidth(width int) int {
227229
if width < LyricsDrawerMinWidth {
228230
return 0
@@ -237,6 +239,34 @@ func LyricsDrawerWidth(width int) int {
237239
return w
238240
}
239241

242+
// LyricsSurface says where the lyric sheet is drawn at a given terminal size.
243+
type LyricsSurface int
244+
245+
const (
246+
// LyricsSurfaceHidden means the sheet is not on screen.
247+
LyricsSurfaceHidden LyricsSurface = iota
248+
// LyricsSurfaceDrawer puts the sheet beside the station list.
249+
LyricsSurfaceDrawer
250+
// LyricsSurfaceOverlay gives the sheet the whole content area, for
251+
// terminals too narrow to show both.
252+
LyricsSurfaceOverlay
253+
)
254+
255+
// lyricsSurface reports where the sheet goes and how many columns it gets.
256+
func (m Model) lyricsSurface() (LyricsSurface, int) {
257+
if !m.ShowLyrics {
258+
return LyricsSurfaceHidden, 0
259+
}
260+
width := m.Width
261+
if width == 0 {
262+
width = 80
263+
}
264+
if drawer := LyricsDrawerWidth(width); drawer > 0 {
265+
return LyricsSurfaceDrawer, drawer
266+
}
267+
return LyricsSurfaceOverlay, width
268+
}
269+
240270
// artTarget returns the cell dimensions artwork should be rendered at for the
241271
// currently visible surface, or zeroes when artwork has nowhere to go.
242272
func (m Model) artTarget() (cols, rows int) {
@@ -264,14 +294,11 @@ func (m Model) artTarget() (cols, rows int) {
264294
return cols, rows
265295
}
266296

267-
if !m.ShowLyrics {
268-
return 0, 0
269-
}
270-
drawer := LyricsDrawerWidth(width)
271-
if drawer == 0 {
297+
surface, surfaceWidth := m.lyricsSurface()
298+
if surface == LyricsSurfaceHidden {
272299
return 0, 0
273300
}
274-
cols = drawer - 6
301+
cols = surfaceWidth - 6
275302
if cols > 20 {
276303
cols = 20
277304
}
@@ -390,11 +417,6 @@ func (m *Model) toggleLyricsDrawer() []tea.Cmd {
390417
m.renderArt()
391418
return nil
392419
}
393-
if LyricsDrawerWidth(m.Width) == 0 && m.Width > 0 {
394-
m.StatusMessage = fmt.Sprintf("Terminal too narrow for the lyrics drawer (needs %d columns)", LyricsDrawerMinWidth)
395-
return nil
396-
}
397-
398420
m.ShowLyrics = true
399421
m.ActiveFocus = FocusLyrics
400422
cmds := m.syncNowPlaying()

pkg/ui/nowplaying_test.go

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,59 @@ func TestLyricsKey_TogglesDrawerAndFocus(t *testing.T) {
5151
}
5252
}
5353

54-
func TestLyricsKey_RefusedOnNarrowTerminal(t *testing.T) {
55-
m := sizedTestModel(70, 20)
54+
func TestLyricsKey_FallsBackToOverlayOnNarrowTerminal(t *testing.T) {
55+
m := sizedTestModel(70, 24)
5656

5757
updated, _ := m.Update(keyRune('L'))
5858
m = updated.(Model)
59-
if m.ShowLyrics {
60-
t.Error("expected the drawer to stay closed on a 60 column terminal")
59+
if !m.ShowLyrics {
60+
t.Fatal("expected L to open the sheet even on a 70 column terminal")
61+
}
62+
63+
surface, surfaceWidth := m.lyricsSurface()
64+
if surface != LyricsSurfaceOverlay {
65+
t.Errorf("expected the overlay surface below %d columns, got %v", LyricsDrawerMinWidth, surface)
66+
}
67+
if surfaceWidth != 70 {
68+
t.Errorf("expected the overlay to take the full width, got %d", surfaceWidth)
69+
}
70+
71+
out := m.View()
72+
if !strings.Contains(out, "LIVE LYRICS") {
73+
t.Error("expected the sheet to be visible on a narrow terminal")
74+
}
75+
// The overlay replaces the station list rather than squeezing beside it.
76+
if strings.Contains(out, "Ambient Two") {
77+
t.Error("expected the overlay to take over the content area")
78+
}
79+
if got := lipgloss.Width(out); got > 70 {
80+
t.Errorf("overlay view is %d columns wide, exceeds the terminal", got)
81+
}
82+
}
83+
84+
func TestLyricsSurface_SwitchesOnResize(t *testing.T) {
85+
m := sizedTestModel(120, 40)
86+
updated, _ := m.Update(keyRune('L'))
87+
m = updated.(Model)
88+
89+
if surface, _ := m.lyricsSurface(); surface != LyricsSurfaceDrawer {
90+
t.Fatalf("expected a drawer at 120 columns, got %v", surface)
6191
}
62-
if !strings.Contains(m.StatusMessage, "too narrow") {
63-
t.Errorf("expected a width warning, got %q", m.StatusMessage)
92+
93+
// Shrinking the window must not make the open sheet vanish.
94+
updated, _ = m.Update(tea.WindowSizeMsg{Width: 70, Height: 24})
95+
m = updated.(Model)
96+
if surface, _ := m.lyricsSurface(); surface != LyricsSurfaceOverlay {
97+
t.Errorf("expected the sheet to become an overlay after shrinking, got %v", surface)
98+
}
99+
if !strings.Contains(m.View(), "LIVE LYRICS") {
100+
t.Error("expected the sheet to stay visible after shrinking")
101+
}
102+
103+
updated, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
104+
m = updated.(Model)
105+
if surface, _ := m.lyricsSurface(); surface != LyricsSurfaceDrawer {
106+
t.Error("expected the drawer to come back when the window grows")
64107
}
65108
}
66109

pkg/ui/view.go

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -177,22 +177,17 @@ func (m Model) View() string {
177177
mainContentHeight = 3
178178
}
179179

180-
// The lyrics drawer steals columns from the main content area so the
181-
// station list keeps its own layout instead of being overlapped.
180+
// Beside the station list the drawer steals columns so the list keeps its
181+
// own layout. On a terminal too narrow for both, the sheet takes over the
182+
// content area instead of silently declining to appear.
183+
lyricsSurface, lyricsWidth := m.lyricsSurface()
182184
contentWidth := width
183-
drawerWidth := 0
184-
if m.ShowLyrics {
185-
drawerWidth = LyricsDrawerWidth(width)
186-
if drawerWidth > 0 {
187-
contentWidth = width - drawerWidth - 1
188-
if contentWidth < 24 {
189-
contentWidth = 24
190-
drawerWidth = width - contentWidth - 1
191-
if drawerWidth < 24 {
192-
drawerWidth = 0
193-
contentWidth = width
194-
}
195-
}
185+
if lyricsSurface == LyricsSurfaceDrawer {
186+
contentWidth = width - lyricsWidth - 1
187+
if contentWidth < 24 {
188+
lyricsSurface = LyricsSurfaceOverlay
189+
lyricsWidth = width
190+
contentWidth = width
196191
}
197192
}
198193

@@ -346,8 +341,8 @@ func (m Model) View() string {
346341
)
347342
}
348343

349-
if drawerWidth > 0 {
350-
drawerView := components.RenderLyricsDrawer(components.LyricsDrawerInput{
344+
if lyricsSurface != LyricsSurfaceHidden {
345+
lyricsView := components.RenderLyricsDrawer(components.LyricsDrawerInput{
351346
Sheet: m.LyricsSheet,
352347
Status: m.LyricsStatus,
353348
Fetching: m.IsFetchingLyrics,
@@ -359,10 +354,14 @@ func (m Model) View() string {
359354
Offset: m.LyricsOffset,
360355
Scroll: m.LyricsScroll,
361356
Focused: m.ActiveFocus == FocusLyrics,
362-
Width: drawerWidth,
357+
Width: lyricsWidth,
363358
Height: mainContentHeight,
364359
}, m.Theme)
365-
mainArea = lipgloss.JoinHorizontal(lipgloss.Top, mainArea, " ", drawerView)
360+
if lyricsSurface == LyricsSurfaceDrawer {
361+
mainArea = lipgloss.JoinHorizontal(lipgloss.Top, mainArea, " ", lyricsView)
362+
} else {
363+
mainArea = lyricsView
364+
}
366365
}
367366

368367
return artClear + lipgloss.JoinVertical(

0 commit comments

Comments
 (0)