Add cliamp.message plugin API for status bar messages
Release / build (amd64, darwin, macos-latest) (push) Has been cancelled
Release / build (amd64, linux, ubuntu-latest) (push) Has been cancelled
Release / build (amd64, windows, windows-latest) (push) Has been cancelled
Release / build (arm64, darwin, macos-latest) (push) Has been cancelled
Release / build (arm64, linux, ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / update-homebrew (push) Has been cancelled
Release / build (amd64, darwin, macos-latest) (push) Has been cancelled
Release / build (amd64, linux, ubuntu-latest) (push) Has been cancelled
Release / build (amd64, windows, windows-latest) (push) Has been cancelled
Release / build (arm64, darwin, macos-latest) (push) Has been cancelled
Release / build (arm64, linux, ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / update-homebrew (push) Has been cancelled
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }))
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user