1package plugin
2
3import (
4 "log"
5 "os"
6 "path/filepath"
7 "strings"
8
9 lua "github.com/yuin/gopher-lua"
10)
11
12// Manager manages the Lua VM and loaded plugins.
13type Manager struct {
14 state *lua.LState
15 hooks map[string][]*lua.LFunction
16 plugins []string
17 // statuses holds persistent status strings per view area, shown in the UI.
18 statuses map[string]string
19 // pendingNotification is set by matcha.notify() and consumed by the orchestrator.
20 pendingNotification string
21 pendingDuration float64 // seconds, 0 means default (2s)
22 // pendingFields holds compose field updates set by matcha.set_compose_field().
23 pendingFields map[string]string
24}
25
26// NewManager creates a new plugin manager with a Lua VM.
27func NewManager() *Manager {
28 m := &Manager{
29 hooks: make(map[string][]*lua.LFunction),
30 statuses: make(map[string]string),
31 pendingFields: make(map[string]string),
32 }
33
34 L := lua.NewState(lua.Options{
35 SkipOpenLibs: true,
36 })
37
38 // Open only safe standard libraries (no os, io, debug)
39 for _, lib := range []struct {
40 name string
41 fn lua.LGFunction
42 }{
43 {lua.LoadLibName, lua.OpenPackage},
44 {lua.BaseLibName, lua.OpenBase},
45 {lua.TabLibName, lua.OpenTable},
46 {lua.StringLibName, lua.OpenString},
47 {lua.MathLibName, lua.OpenMath},
48 } {
49 L.Push(L.NewFunction(lib.fn))
50 L.Push(lua.LString(lib.name))
51 L.Call(1, 0)
52 }
53
54 m.state = L
55 m.registerAPI()
56
57 return m
58}
59
60// LoadPlugins discovers and loads plugins from ~/.config/matcha/plugins/.
61func (m *Manager) LoadPlugins() {
62 home, err := os.UserHomeDir()
63 if err != nil {
64 return
65 }
66
67 pluginsDir := filepath.Join(home, ".config", "matcha", "plugins")
68 entries, err := os.ReadDir(pluginsDir)
69 if err != nil {
70 return
71 }
72
73 for _, entry := range entries {
74 path := filepath.Join(pluginsDir, entry.Name())
75
76 if entry.IsDir() {
77 // Directory plugin: look for init.lua
78 initPath := filepath.Join(path, "init.lua")
79 if _, err := os.Stat(initPath); err == nil {
80 m.loadPlugin(entry.Name(), initPath)
81 }
82 } else if strings.HasSuffix(entry.Name(), ".lua") {
83 // Single-file plugin
84 name := strings.TrimSuffix(entry.Name(), ".lua")
85 m.loadPlugin(name, path)
86 }
87 }
88}
89
90func (m *Manager) loadPlugin(name, path string) {
91 if err := m.state.DoFile(path); err != nil {
92 log.Printf("plugin %q: load error: %v", name, err)
93 return
94 }
95 m.plugins = append(m.plugins, name)
96 log.Printf("plugin %q: loaded", name)
97}
98
99// Plugins returns the names of all loaded plugins.
100func (m *Manager) Plugins() []string {
101 return m.plugins
102}
103
104// PendingNotification holds a notification message and its display duration.
105type PendingNotification struct {
106 Message string
107 Duration float64 // seconds, 0 means default
108}
109
110// TakePendingNotification returns and clears any pending notification.
111func (m *Manager) TakePendingNotification() (PendingNotification, bool) {
112 if m.pendingNotification == "" {
113 return PendingNotification{}, false
114 }
115 n := PendingNotification{
116 Message: m.pendingNotification,
117 Duration: m.pendingDuration,
118 }
119 m.pendingNotification = ""
120 m.pendingDuration = 0
121 return n, true
122}
123
124// TakePendingFields returns and clears any pending compose field updates.
125func (m *Manager) TakePendingFields() map[string]string {
126 if len(m.pendingFields) == 0 {
127 return nil
128 }
129 fields := m.pendingFields
130 m.pendingFields = make(map[string]string)
131 return fields
132}
133
134// StatusText returns the plugin status string for the given view area.
135func (m *Manager) StatusText(area string) string {
136 return m.statuses[area]
137}
138
139// Close shuts down the Lua VM.
140func (m *Manager) Close() {
141 if m.state != nil {
142 m.state.Close()
143 }
144}