Skip to content

Commit 2f7044d

Browse files
committed
Add cliamp.message plugin API for status bar messages
Plugins can now call cliamp.message(text, duration_secs?) to display transient messages in the UI status bar. Closes #175. Delivery flows through prog.Send to the Bubbletea model thread, so plugin timers and event handlers never touch UI state directly.
1 parent 3cabb32 commit 2f7044d

8 files changed

Lines changed: 198 additions & 0 deletions

File tree

docs/plugins.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,16 @@ cliamp.notify("Song Title", "Artist Name") -- notification with title and body
297297

298298
Sends a desktop notification via `notify-send`. Works with mako, dunst, and other notification daemons.
299299

300+
### cliamp.message
301+
302+
```lua
303+
cliamp.message("Scrobble Sent") -- show for default duration
304+
cliamp.message("Syncing Library", 5) -- show for 5 seconds
305+
```
306+
307+
Displays a transient message in the status bar at the bottom of the UI. The
308+
duration argument is optional (seconds); omit it to use the default TTL. Durations above 60 seconds are clamped.
309+
300310
### cliamp.sleep
301311

302312
```lua

luaplugin/api_message.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package luaplugin
2+
3+
import (
4+
"time"
5+
6+
lua "github.com/yuin/gopher-lua"
7+
)
8+
9+
// messageMaxDuration caps how long a plugin-supplied status message can stay on
10+
// screen. Plugins can request longer durations, but they get clamped so a
11+
// runaway script cannot pin the status bar indefinitely.
12+
const messageMaxDuration = 60 * time.Second
13+
14+
// registerMessageAPI adds cliamp.message(text, duration_secs?) which displays a
15+
// temporary message in the status bar at the bottom of the UI. A missing or
16+
// non-positive duration falls back to the default status TTL (set by the UI).
17+
func registerMessageAPI(L *lua.LState, cliamp *lua.LTable, ui *UIProvider) {
18+
L.SetField(cliamp, "message", L.NewFunction(func(L *lua.LState) int {
19+
if ui.ShowMessage == nil {
20+
return 0
21+
}
22+
text := L.CheckString(1)
23+
var dur time.Duration
24+
if secs := float64(L.OptNumber(2, 0)); secs > 0 {
25+
dur = min(time.Duration(secs*float64(time.Second)), messageMaxDuration)
26+
}
27+
ui.ShowMessage(text, dur)
28+
return 0
29+
}))
30+
}

luaplugin/api_message_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package luaplugin
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestMessageAPIDeliversTextAndDuration(t *testing.T) {
9+
m := newTestManager()
10+
var gotText string
11+
var gotDur time.Duration
12+
m.SetUIProvider(UIProvider{
13+
ShowMessage: func(text string, duration time.Duration) {
14+
gotText = text
15+
gotDur = duration
16+
},
17+
})
18+
19+
loadTestPlugin(t, m, "msg-test", `
20+
plugin.register({name = "msg-test", type = "hook"})
21+
cliamp.message("Scrobble Sent", 2)
22+
`)
23+
24+
if gotText != "Scrobble Sent" {
25+
t.Fatalf("text = %q, want %q", gotText, "Scrobble Sent")
26+
}
27+
if gotDur != 2*time.Second {
28+
t.Fatalf("duration = %v, want 2s", gotDur)
29+
}
30+
}
31+
32+
func TestMessageAPIDefaultsDurationToZero(t *testing.T) {
33+
m := newTestManager()
34+
var gotDur time.Duration
35+
seen := false
36+
m.SetUIProvider(UIProvider{
37+
ShowMessage: func(_ string, duration time.Duration) {
38+
gotDur = duration
39+
seen = true
40+
},
41+
})
42+
43+
loadTestPlugin(t, m, "msg-default", `
44+
plugin.register({name = "msg-default", type = "hook"})
45+
cliamp.message("hello")
46+
`)
47+
48+
if !seen {
49+
t.Fatal("ShowMessage was not called")
50+
}
51+
if gotDur != 0 {
52+
t.Fatalf("duration = %v, want 0 (UI decides default)", gotDur)
53+
}
54+
}
55+
56+
func TestMessageAPIClampsMaxDuration(t *testing.T) {
57+
m := newTestManager()
58+
var gotDur time.Duration
59+
m.SetUIProvider(UIProvider{
60+
ShowMessage: func(_ string, duration time.Duration) { gotDur = duration },
61+
})
62+
63+
loadTestPlugin(t, m, "msg-clamp", `
64+
plugin.register({name = "msg-clamp", type = "hook"})
65+
cliamp.message("long", 9999)
66+
`)
67+
68+
if gotDur != messageMaxDuration {
69+
t.Fatalf("duration = %v, want %v (clamped)", gotDur, messageMaxDuration)
70+
}
71+
}
72+
73+
func TestMessageAPIWithoutProviderIsNoop(t *testing.T) {
74+
m := newTestManager()
75+
// No SetUIProvider call — ShowMessage is nil.
76+
loadTestPlugin(t, m, "msg-noop", `
77+
plugin.register({name = "msg-noop", type = "hook"})
78+
cliamp.message("nobody listening")
79+
`)
80+
// Success = no panic / no error from loadPlugin above.
81+
}

luaplugin/luaplugin.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"sort"
1111
"strings"
1212
"sync"
13+
"time"
1314

1415
lua "github.com/yuin/gopher-lua"
1516

@@ -69,6 +70,12 @@ type ControlProvider struct {
6970
Prev func() // injected via prog.Send
7071
}
7172

73+
// UIProvider supplies callbacks that surface plugin output in the TUI.
74+
// Not permission-gated — these are low-risk, output-only operations.
75+
type UIProvider struct {
76+
ShowMessage func(text string, duration time.Duration) // injected via prog.Send
77+
}
78+
7279
// Manager owns all loaded plugins and dispatches events to them.
7380
type Manager struct {
7481
plugins []*Plugin
@@ -77,6 +84,7 @@ type Manager struct {
7784
visMap map[string]*luaVis // name -> Lua visualizer
7885
state StateProvider
7986
control ControlProvider
87+
ui UIProvider
8088
timers *timerManager
8189
logger *pluginLogger
8290
mu sync.RWMutex
@@ -336,6 +344,7 @@ func (m *Manager) registerCliampAPI(L *lua.LState, p *Plugin) {
336344
registerTimerAPI(L, cliamp, m.timers, p)
337345
registerNotifyAPI(L, cliamp, m.logger, p.Name)
338346
registerControlAPI(L, cliamp, &m.control, p, m.logger)
347+
registerMessageAPI(L, cliamp, &m.ui)
339348
registerSleepAPI(L, cliamp)
340349
L.SetGlobal("cliamp", cliamp)
341350
}
@@ -352,6 +361,11 @@ func (m *Manager) SetControlProvider(cp ControlProvider) {
352361
m.control = cp
353362
}
354363

364+
// SetUIProvider sets the function pointers for UI output (status messages).
365+
func (m *Manager) SetUIProvider(up UIProvider) {
366+
m.ui = up
367+
}
368+
355369
// Close fires the "app.quit" event synchronously and shuts down all Lua VMs.
356370
func (m *Manager) Close() {
357371
m.EmitSync(EventAppQuit, nil)

main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,11 @@ func run(overrides config.Overrides, positional []string) error {
302302
Next: func() { prog.Send(playback.NextMsg{}) },
303303
Prev: func() { prog.Send(playback.PrevMsg{}) },
304304
})
305+
luaMgr.SetUIProvider(luaplugin.UIProvider{
306+
ShowMessage: func(text string, duration time.Duration) {
307+
prog.Send(model.ShowStatusMsg{Text: text, Duration: duration})
308+
},
309+
})
305310
}
306311

307312
ipcSrv, ipcErr := ipc.NewServer(ipc.DefaultSocketPath(), ipc.DispatcherFunc(func(msg any) { prog.Send(msg) }))

plugins/status-messages.lua

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
-- status-messages.lua — Demo of cliamp.message(): surface playback events
2+
-- as transient messages in the status bar at the bottom of the UI.
3+
--
4+
-- Install by copying (or symlinking) this file to ~/.config/cliamp/plugins/
5+
-- and restart cliamp.
6+
7+
local p = plugin.register({
8+
name = "status-messages",
9+
type = "hook",
10+
description = "Show playback events in the status bar",
11+
})
12+
13+
p:on("app.start", function()
14+
cliamp.message("cliamp ready", 2)
15+
end)
16+
17+
p:on("track.change", function(track)
18+
local text = track.title or ""
19+
if track.artist and track.artist ~= "" then
20+
text = track.artist .. "" .. text
21+
end
22+
cliamp.message("Now playing: " .. text, 3)
23+
end)
24+
25+
-- playback.state fires on every tick (~1Hz) during playback, not just on
26+
-- state transitions. Track the last status locally so the status bar is
27+
-- only updated when it actually changes.
28+
local last_status = nil
29+
p:on("playback.state", function(ev)
30+
if ev.status == last_status then
31+
return
32+
end
33+
last_status = ev.status
34+
if ev.status == "paused" then
35+
cliamp.message("Paused", 1.5)
36+
elseif ev.status == "stopped" then
37+
cliamp.message("Stopped", 1.5)
38+
end
39+
end)
40+
41+
p:on("track.scrobble", function()
42+
cliamp.message("Scrobble sent", 2)
43+
end)

ui/model/commands.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ type SetEQPresetMsg struct {
3535
Bands *[10]float64 // nil = use built-in preset bands or keep current
3636
}
3737

38+
// ShowStatusMsg is sent by Lua plugins to display a message in the status bar.
39+
// Duration <= 0 falls back to the default status TTL.
40+
type ShowStatusMsg struct {
41+
Text string
42+
Duration time.Duration
43+
}
44+
3845
type tracksLoadedMsg []playlist.Track
3946

4047
// feedsLoadedMsg carries tracks resolved from remote feed/M3U URLs,

ui/model/update.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
687687
m.SetEQPreset(msg.Name, msg.Bands)
688688
return m, nil
689689

690+
case ShowStatusMsg:
691+
ttl := statusTTLDefault
692+
if msg.Duration > 0 {
693+
ttl = statusTTL(msg.Duration)
694+
}
695+
m.status.Show(msg.Text, ttl)
696+
return m, nil
697+
690698
// IPC-specific messages (PlayMsg, PauseMsg have different semantics from toggle).
691699
// Shared types (NextMsg, PrevMsg, StopMsg, PlayPauseMsg) are handled above via
692700
// playback.* types.

0 commit comments

Comments
 (0)