From 2f7044d0716577bd976c7bbb97104a03e09df948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bjarne=20=C3=98verli?= Date: Fri, 17 Apr 2026 19:06:13 +0200 Subject: [PATCH] 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. --- docs/plugins.md | 10 +++++ luaplugin/api_message.go | 30 +++++++++++++ luaplugin/api_message_test.go | 81 +++++++++++++++++++++++++++++++++++ luaplugin/luaplugin.go | 14 ++++++ main.go | 5 +++ plugins/status-messages.lua | 43 +++++++++++++++++++ ui/model/commands.go | 7 +++ ui/model/update.go | 8 ++++ 8 files changed, 198 insertions(+) create mode 100644 luaplugin/api_message.go create mode 100644 luaplugin/api_message_test.go create mode 100644 plugins/status-messages.lua diff --git a/docs/plugins.md b/docs/plugins.md index 99665b0..02add6c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -297,6 +297,16 @@ cliamp.notify("Song Title", "Artist Name") -- notification with title and body Sends a desktop notification via `notify-send`. Works with mako, dunst, and other notification daemons. +### cliamp.message + +```lua +cliamp.message("Scrobble Sent") -- show for default duration +cliamp.message("Syncing Library", 5) -- show for 5 seconds +``` + +Displays a transient message in the status bar at the bottom of the UI. The +duration argument is optional (seconds); omit it to use the default TTL. Durations above 60 seconds are clamped. + ### cliamp.sleep ```lua diff --git a/luaplugin/api_message.go b/luaplugin/api_message.go new file mode 100644 index 0000000..a8bafc6 --- /dev/null +++ b/luaplugin/api_message.go @@ -0,0 +1,30 @@ +package luaplugin + +import ( + "time" + + lua "github.com/yuin/gopher-lua" +) + +// messageMaxDuration caps how long a plugin-supplied status message can stay on +// screen. Plugins can request longer durations, but they get clamped so a +// runaway script cannot pin the status bar indefinitely. +const messageMaxDuration = 60 * time.Second + +// registerMessageAPI adds cliamp.message(text, duration_secs?) which displays a +// temporary message in the status bar at the bottom of the UI. A missing or +// non-positive duration falls back to the default status TTL (set by the UI). +func registerMessageAPI(L *lua.LState, cliamp *lua.LTable, ui *UIProvider) { + L.SetField(cliamp, "message", L.NewFunction(func(L *lua.LState) int { + if ui.ShowMessage == nil { + return 0 + } + text := L.CheckString(1) + var dur time.Duration + if secs := float64(L.OptNumber(2, 0)); secs > 0 { + dur = min(time.Duration(secs*float64(time.Second)), messageMaxDuration) + } + ui.ShowMessage(text, dur) + return 0 + })) +} diff --git a/luaplugin/api_message_test.go b/luaplugin/api_message_test.go new file mode 100644 index 0000000..c3d09ef --- /dev/null +++ b/luaplugin/api_message_test.go @@ -0,0 +1,81 @@ +package luaplugin + +import ( + "testing" + "time" +) + +func TestMessageAPIDeliversTextAndDuration(t *testing.T) { + m := newTestManager() + var gotText string + var gotDur time.Duration + m.SetUIProvider(UIProvider{ + ShowMessage: func(text string, duration time.Duration) { + gotText = text + gotDur = duration + }, + }) + + loadTestPlugin(t, m, "msg-test", ` + plugin.register({name = "msg-test", type = "hook"}) + cliamp.message("Scrobble Sent", 2) + `) + + if gotText != "Scrobble Sent" { + t.Fatalf("text = %q, want %q", gotText, "Scrobble Sent") + } + if gotDur != 2*time.Second { + t.Fatalf("duration = %v, want 2s", gotDur) + } +} + +func TestMessageAPIDefaultsDurationToZero(t *testing.T) { + m := newTestManager() + var gotDur time.Duration + seen := false + m.SetUIProvider(UIProvider{ + ShowMessage: func(_ string, duration time.Duration) { + gotDur = duration + seen = true + }, + }) + + loadTestPlugin(t, m, "msg-default", ` + plugin.register({name = "msg-default", type = "hook"}) + cliamp.message("hello") + `) + + if !seen { + t.Fatal("ShowMessage was not called") + } + if gotDur != 0 { + t.Fatalf("duration = %v, want 0 (UI decides default)", gotDur) + } +} + +func TestMessageAPIClampsMaxDuration(t *testing.T) { + m := newTestManager() + var gotDur time.Duration + m.SetUIProvider(UIProvider{ + ShowMessage: func(_ string, duration time.Duration) { gotDur = duration }, + }) + + loadTestPlugin(t, m, "msg-clamp", ` + plugin.register({name = "msg-clamp", type = "hook"}) + cliamp.message("long", 9999) + `) + + if gotDur != messageMaxDuration { + t.Fatalf("duration = %v, want %v (clamped)", gotDur, messageMaxDuration) + } +} + +func TestMessageAPIWithoutProviderIsNoop(t *testing.T) { + m := newTestManager() + // No SetUIProvider call — ShowMessage is nil. + loadTestPlugin(t, m, "msg-noop", ` + plugin.register({name = "msg-noop", type = "hook"}) + cliamp.message("nobody listening") + `) + // Success = no panic / no error from loadPlugin above. +} diff --git a/luaplugin/luaplugin.go b/luaplugin/luaplugin.go index 6319b42..679adbe 100644 --- a/luaplugin/luaplugin.go +++ b/luaplugin/luaplugin.go @@ -10,6 +10,7 @@ import ( "sort" "strings" "sync" + "time" lua "github.com/yuin/gopher-lua" @@ -69,6 +70,12 @@ type ControlProvider struct { Prev func() // injected via prog.Send } +// UIProvider supplies callbacks that surface plugin output in the TUI. +// Not permission-gated — these are low-risk, output-only operations. +type UIProvider struct { + ShowMessage func(text string, duration time.Duration) // injected via prog.Send +} + // Manager owns all loaded plugins and dispatches events to them. type Manager struct { plugins []*Plugin @@ -77,6 +84,7 @@ type Manager struct { visMap map[string]*luaVis // name -> Lua visualizer state StateProvider control ControlProvider + ui UIProvider timers *timerManager logger *pluginLogger mu sync.RWMutex @@ -336,6 +344,7 @@ func (m *Manager) registerCliampAPI(L *lua.LState, p *Plugin) { registerTimerAPI(L, cliamp, m.timers, p) registerNotifyAPI(L, cliamp, m.logger, p.Name) registerControlAPI(L, cliamp, &m.control, p, m.logger) + registerMessageAPI(L, cliamp, &m.ui) registerSleepAPI(L, cliamp) L.SetGlobal("cliamp", cliamp) } @@ -352,6 +361,11 @@ func (m *Manager) SetControlProvider(cp ControlProvider) { m.control = cp } +// SetUIProvider sets the function pointers for UI output (status messages). +func (m *Manager) SetUIProvider(up UIProvider) { + m.ui = up +} + // Close fires the "app.quit" event synchronously and shuts down all Lua VMs. func (m *Manager) Close() { m.EmitSync(EventAppQuit, nil) diff --git a/main.go b/main.go index 8088aba..f7482e3 100644 --- a/main.go +++ b/main.go @@ -302,6 +302,11 @@ func run(overrides config.Overrides, positional []string) error { Next: func() { prog.Send(playback.NextMsg{}) }, Prev: func() { prog.Send(playback.PrevMsg{}) }, }) + luaMgr.SetUIProvider(luaplugin.UIProvider{ + ShowMessage: func(text string, duration time.Duration) { + prog.Send(model.ShowStatusMsg{Text: text, Duration: duration}) + }, + }) } ipcSrv, ipcErr := ipc.NewServer(ipc.DefaultSocketPath(), ipc.DispatcherFunc(func(msg any) { prog.Send(msg) })) diff --git a/plugins/status-messages.lua b/plugins/status-messages.lua new file mode 100644 index 0000000..d5b5b7e --- /dev/null +++ b/plugins/status-messages.lua @@ -0,0 +1,43 @@ +-- status-messages.lua — Demo of cliamp.message(): surface playback events +-- as transient messages in the status bar at the bottom of the UI. +-- +-- Install by copying (or symlinking) this file to ~/.config/cliamp/plugins/ +-- and restart cliamp. + +local p = plugin.register({ + name = "status-messages", + type = "hook", + description = "Show playback events in the status bar", +}) + +p:on("app.start", function() + cliamp.message("cliamp ready", 2) +end) + +p:on("track.change", function(track) + local text = track.title or "" + if track.artist and track.artist ~= "" then + text = track.artist .. " — " .. text + end + cliamp.message("Now playing: " .. text, 3) +end) + +-- playback.state fires on every tick (~1Hz) during playback, not just on +-- state transitions. Track the last status locally so the status bar is +-- only updated when it actually changes. +local last_status = nil +p:on("playback.state", function(ev) + if ev.status == last_status then + return + end + last_status = ev.status + if ev.status == "paused" then + cliamp.message("Paused", 1.5) + elseif ev.status == "stopped" then + cliamp.message("Stopped", 1.5) + end +end) + +p:on("track.scrobble", function() + cliamp.message("Scrobble sent", 2) +end) diff --git a/ui/model/commands.go b/ui/model/commands.go index 7275ad8..01895c9 100644 --- a/ui/model/commands.go +++ b/ui/model/commands.go @@ -35,6 +35,13 @@ type SetEQPresetMsg struct { Bands *[10]float64 // nil = use built-in preset bands or keep current } +// ShowStatusMsg is sent by Lua plugins to display a message in the status bar. +// Duration <= 0 falls back to the default status TTL. +type ShowStatusMsg struct { + Text string + Duration time.Duration +} + type tracksLoadedMsg []playlist.Track // feedsLoadedMsg carries tracks resolved from remote feed/M3U URLs, diff --git a/ui/model/update.go b/ui/model/update.go index 588cf99..c2581f5 100644 --- a/ui/model/update.go +++ b/ui/model/update.go @@ -687,6 +687,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.SetEQPreset(msg.Name, msg.Bands) return m, nil + case ShowStatusMsg: + ttl := statusTTLDefault + if msg.Duration > 0 { + ttl = statusTTL(msg.Duration) + } + m.status.Show(msg.Text, ttl) + return m, nil + // IPC-specific messages (PlayMsg, PauseMsg have different semantics from toggle). // Shared types (NextMsg, PrevMsg, StopMsg, PlayPauseMsg) are handled above via // playback.* types.