mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 09:42:34 +02:00
Init
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/paginator"
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/plugins"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
manager *plugins.Manager
|
||||
items []plugins.Info
|
||||
cursor int
|
||||
editing bool
|
||||
filter string
|
||||
filtered []plugins.Info
|
||||
|
||||
listViewport viewport.Model
|
||||
textarea textarea.Model
|
||||
filterInput textinput.Model
|
||||
filtering bool
|
||||
pager paginator.Model
|
||||
help help.Model
|
||||
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func New(mgr *plugins.Manager) Model {
|
||||
ta := style.NewTextarea(false)
|
||||
ta.Placeholder = "plugin configuration..."
|
||||
ta.Blur()
|
||||
|
||||
fi := textinput.New()
|
||||
fi.Prompt = ""
|
||||
|
||||
return Model{
|
||||
manager: mgr,
|
||||
listViewport: style.NewViewport(),
|
||||
textarea: ta,
|
||||
filterInput: fi,
|
||||
pager: style.NewPaginator(),
|
||||
help: style.NewHelp(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m Model) IsEditing() bool { return m.editing || m.filtering }
|
||||
|
||||
func (m *Model) SetSize(w, h int) {
|
||||
m.width = w
|
||||
m.height = h
|
||||
m.recalcSizes()
|
||||
}
|
||||
|
||||
func (m *Model) recalcSizes() {
|
||||
if m.width == 0 {
|
||||
return
|
||||
}
|
||||
m.help.SetWidth(m.width - 2)
|
||||
|
||||
listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4)
|
||||
|
||||
inner := m.width - 2
|
||||
if inner < 0 {
|
||||
inner = 0
|
||||
}
|
||||
|
||||
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
|
||||
if listVH < 0 {
|
||||
listVH = 0
|
||||
}
|
||||
m.listViewport.SetWidth(inner)
|
||||
m.listViewport.SetHeight(listVH)
|
||||
m.pager.PerPage = listVH
|
||||
if m.pager.PerPage < 1 {
|
||||
m.pager.PerPage = 1
|
||||
}
|
||||
|
||||
m.filterInput.SetWidth(inner - 2)
|
||||
m.textarea.SetWidth(max(1, inner-2))
|
||||
m.textarea.SetHeight(max(3, detailH-6))
|
||||
|
||||
m.refreshListViewport()
|
||||
}
|
||||
|
||||
// Refresh reloads the plugin list from the manager.
|
||||
func (m *Model) Refresh() {
|
||||
if m.manager == nil {
|
||||
return
|
||||
}
|
||||
pl := m.manager.GetPlugins()
|
||||
m.items = make([]plugins.Info, len(pl))
|
||||
for i, p := range pl {
|
||||
m.items[i] = p.Info()
|
||||
}
|
||||
m.applyFilter()
|
||||
}
|
||||
|
||||
func (m *Model) applyFilter() {
|
||||
if m.filter == "" {
|
||||
m.filtered = m.items
|
||||
} else {
|
||||
f := strings.ToLower(m.filter)
|
||||
filtered := make([]plugins.Info, 0, len(m.items))
|
||||
for _, p := range m.items {
|
||||
if strings.Contains(strings.ToLower(p.Name), f) {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
m.filtered = filtered
|
||||
}
|
||||
m.pager.SetTotalPages(len(m.filtered))
|
||||
if m.cursor >= len(m.filtered) {
|
||||
m.cursor = max(0, len(m.filtered)-1)
|
||||
}
|
||||
m.refreshListViewport()
|
||||
m.syncTextarea()
|
||||
}
|
||||
|
||||
func (m *Model) selected() (plugins.Info, bool) {
|
||||
if len(m.filtered) == 0 {
|
||||
return plugins.Info{}, false
|
||||
}
|
||||
return m.filtered[m.cursor], true
|
||||
}
|
||||
|
||||
func (m *Model) syncTextarea() {
|
||||
if m.editing {
|
||||
return
|
||||
}
|
||||
info, ok := m.selected()
|
||||
if !ok {
|
||||
m.textarea.SetValue("")
|
||||
return
|
||||
}
|
||||
m.textarea.SetValue(info.ConfigText)
|
||||
}
|
||||
|
||||
func (m *Model) refreshListViewport() {
|
||||
if m.pager.PerPage > 0 {
|
||||
m.pager.Page = m.cursor / m.pager.PerPage
|
||||
m.pager.SetTotalPages(len(m.filtered))
|
||||
}
|
||||
m.listViewport.SetContent(m.renderList())
|
||||
}
|
||||
|
||||
func shortenPath(p string) string {
|
||||
home := os.Getenv("HOME")
|
||||
if home != "" && strings.HasPrefix(p, home) {
|
||||
return "~" + p[len(home):]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
type pluginsKeyMap struct{ editing bool }
|
||||
|
||||
func (k pluginsKeyMap) ShortHelp() []key.Binding {
|
||||
pk := keys.Keys.Plugins
|
||||
g := keys.Keys.Global
|
||||
if k.editing {
|
||||
esc := key.NewBinding(key.WithKeys(g.Escape.Keys()...), key.WithHelp(g.Escape.Help().Key, "save & exit"))
|
||||
return []key.Binding{esc}
|
||||
}
|
||||
return []key.Binding{pk.Toggle, pk.EditConfig, pk.Filter}
|
||||
}
|
||||
|
||||
func (k pluginsKeyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{k.ShortHelp()}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
)
|
||||
|
||||
// PluginsChangedMsg is sent when the plugin list should be refreshed.
|
||||
type PluginsChangedMsg struct{}
|
||||
|
||||
// RefreshCmd returns a command that triggers a list refresh.
|
||||
func RefreshCmd() tea.Cmd {
|
||||
return func() tea.Msg { return PluginsChangedMsg{} }
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg.(type) {
|
||||
case PluginsChangedMsg:
|
||||
m.Refresh()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyPressMsg:
|
||||
pk := keys.Keys.Plugins
|
||||
g := keys.Keys.Global
|
||||
|
||||
// Filtering mode: esc clears+closes, enter just closes, rest goes to filterInput.
|
||||
if m.filtering {
|
||||
switch {
|
||||
case key.Matches(msg, g.Escape):
|
||||
m.filtering = false
|
||||
m.filter = ""
|
||||
m.filterInput.SetValue("")
|
||||
m.filterInput.Blur()
|
||||
m.applyFilter()
|
||||
m.recalcSizes()
|
||||
case msg.String() == "enter":
|
||||
m.filtering = false
|
||||
m.filterInput.Blur()
|
||||
m.recalcSizes()
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
m.filterInput, cmd = m.filterInput.Update(msg)
|
||||
m.filter = m.filterInput.Value()
|
||||
m.applyFilter()
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Editing mode: only esc exits, everything else goes to textarea.
|
||||
if m.editing {
|
||||
if key.Matches(msg, g.Escape) {
|
||||
m.editing = false
|
||||
m.textarea.Blur()
|
||||
if info, ok := m.selected(); ok && m.manager != nil {
|
||||
val := m.textarea.Value()
|
||||
m.manager.SaveConfig(info.Name, val)
|
||||
// Update cached info.
|
||||
m.filtered[m.cursor].ConfigText = val
|
||||
for i := range m.items {
|
||||
if m.items[i].Name == info.Name {
|
||||
m.items[i].ConfigText = val
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, g.Escape):
|
||||
if m.filter != "" {
|
||||
m.filter = ""
|
||||
m.filterInput.SetValue("")
|
||||
m.applyFilter()
|
||||
}
|
||||
|
||||
case key.Matches(msg, pk.Filter):
|
||||
m.filtering = true
|
||||
m.filterInput.Focus()
|
||||
m.recalcSizes()
|
||||
|
||||
case key.Matches(msg, g.Up):
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
m.refreshListViewport()
|
||||
m.syncTextarea()
|
||||
}
|
||||
|
||||
case key.Matches(msg, g.Down):
|
||||
if m.cursor < len(m.filtered)-1 {
|
||||
m.cursor++
|
||||
m.refreshListViewport()
|
||||
m.syncTextarea()
|
||||
}
|
||||
|
||||
case key.Matches(msg, pk.Toggle):
|
||||
if info, ok := m.selected(); ok && m.manager != nil {
|
||||
m.manager.TogglePlugin(info.Name)
|
||||
m.filtered[m.cursor].Enabled = !info.Enabled
|
||||
for i := range m.items {
|
||||
if m.items[i].Name == info.Name {
|
||||
m.items[i].Enabled = !info.Enabled
|
||||
break
|
||||
}
|
||||
}
|
||||
m.refreshListViewport()
|
||||
}
|
||||
|
||||
case key.Matches(msg, pk.EditConfig):
|
||||
if _, ok := m.selected(); ok {
|
||||
m.editing = true
|
||||
m.textarea.Focus()
|
||||
}
|
||||
|
||||
case key.Matches(msg, g.Help):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
m.recalcSizes()
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/icons"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
)
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
if m.width == 0 || m.manager == nil {
|
||||
return tea.NewView(style.S.Faint.Render("\nno plugins loaded"))
|
||||
}
|
||||
|
||||
listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4)
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Left,
|
||||
m.renderListPanel(m.width, listH),
|
||||
m.renderDetailPanel(detailH),
|
||||
m.renderStatusBar(),
|
||||
)
|
||||
return tea.NewView(content)
|
||||
}
|
||||
|
||||
func (m *Model) renderListPanel(w, h int) string {
|
||||
s := style.S
|
||||
dots := s.Faint.Render(m.pager.View())
|
||||
inner := lipgloss.JoinVertical(lipgloss.Left,
|
||||
m.listViewport.View(),
|
||||
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
|
||||
)
|
||||
return style.RenderWithTitle(s.PanelFocused, icons.I.Plugin+"Plugins", inner, w, h)
|
||||
}
|
||||
|
||||
func (m *Model) renderDetailPanel(h int) string {
|
||||
s := style.S
|
||||
info, ok := m.selected()
|
||||
if !ok {
|
||||
return style.RenderWithTitle(s.Panel, "Config", "", m.width, h)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
statusSt := lipgloss.NewStyle().Foreground(s.Error)
|
||||
if info.Enabled {
|
||||
statusSt = lipgloss.NewStyle().Foreground(s.Success)
|
||||
}
|
||||
status := "disabled"
|
||||
if info.Enabled {
|
||||
status = "enabled"
|
||||
}
|
||||
sb.WriteString(s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n")
|
||||
sb.WriteString(s.Faint.Render(shortenPath(info.FilePath)) + "\n\n")
|
||||
|
||||
if m.editing {
|
||||
escKey := keys.Keys.Global.Escape.Help().Key
|
||||
sb.WriteString(s.Faint.Render("editing config (" + escKey + " to save):"))
|
||||
} else {
|
||||
editKey := keys.Keys.Plugins.EditConfig.Help().Key
|
||||
sb.WriteString(s.Faint.Render("config (" + editKey + " to edit):"))
|
||||
}
|
||||
|
||||
inner := lipgloss.JoinVertical(lipgloss.Left,
|
||||
lipgloss.NewStyle().Padding(0, 1).Render(sb.String()),
|
||||
lipgloss.NewStyle().Padding(0, 1).Render(m.textarea.View()),
|
||||
)
|
||||
return style.RenderWithTitle(s.Panel, "Detail", inner, m.width, h)
|
||||
}
|
||||
|
||||
func (m *Model) renderStatusBar() string {
|
||||
s := style.S
|
||||
pad := lipgloss.NewStyle().Padding(0, 1)
|
||||
filterKey := keys.Keys.Plugins.Filter.Help().Key
|
||||
if m.filtering {
|
||||
return pad.Render(s.Faint.Render(filterKey) + " " + m.filterInput.View())
|
||||
}
|
||||
if m.filter != "" {
|
||||
escKey := keys.Keys.Global.Escape.Help().Key
|
||||
accent := lipgloss.NewStyle().Foreground(s.Primary)
|
||||
filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.filter) + s.Faint.Render(" "+escKey+" to clear"))
|
||||
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(pluginsKeyMap{editing: m.editing})))
|
||||
}
|
||||
return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing}))
|
||||
}
|
||||
|
||||
func (m *Model) renderList() string {
|
||||
s := style.S
|
||||
if len(m.filtered) == 0 {
|
||||
msg := " (ง •̀_•́)ง\nno plugins"
|
||||
if m.filter != "" {
|
||||
msg = " = _ =\nno results"
|
||||
}
|
||||
return lipgloss.Place(
|
||||
m.listViewport.Width(), m.listViewport.Height(),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
s.Faint.Render(msg),
|
||||
)
|
||||
}
|
||||
|
||||
start, end := m.pager.GetSliceBounds(len(m.filtered))
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i, p := range m.filtered[start:end] {
|
||||
globalIdx := start + i
|
||||
selected := globalIdx == m.cursor
|
||||
|
||||
enabledSt := lipgloss.NewStyle().Foreground(s.Error)
|
||||
enabledStr := "off"
|
||||
if p.Enabled {
|
||||
enabledSt = lipgloss.NewStyle().Foreground(s.Success)
|
||||
enabledStr = "on "
|
||||
}
|
||||
|
||||
w := m.listViewport.Width()
|
||||
const fixedW = 2 + 3 + 1
|
||||
nameW := w - fixedW
|
||||
if nameW < 0 {
|
||||
nameW = 0
|
||||
}
|
||||
|
||||
var line string
|
||||
if selected {
|
||||
bg := lipgloss.NewStyle().Background(s.Selection)
|
||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
||||
enabledSt.Background(s.Selection).Width(3).Render(enabledStr),
|
||||
bg.Width(1).Render(""),
|
||||
bg.Bold(true).Width(nameW).Render(p.Name),
|
||||
)
|
||||
} else {
|
||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
" ",
|
||||
enabledSt.Width(3).Render(enabledStr),
|
||||
" ",
|
||||
s.Bold.Render(p.Name),
|
||||
)
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
Reference in New Issue
Block a user