Files
patterm/internal/app/palette.go
2026-05-14 13:37:20 +01:00

333 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"fmt"
"strings"
"unicode/utf8"
"github.com/harrybrwn/patterm/internal/preset"
)
// paletteAction is what the palette returns when the user picks an item.
type paletteAction struct {
// kind: "spawn-agent" | "spawn-process" | "switch" | "kill" | "quit" | "cancel"
kind string
// For spawn-*, the preset to launch.
preset *preset.Preset
// For "switch" and "kill", the target child id.
childID string
}
type paletteItem struct {
label string
hint string
action paletteAction
}
// paletteState is the in-memory model for the overlay. SPEC §4: a
// single fuzzy-searchable list of commands scoped to the current focus.
type paletteState struct {
query []rune
cursor int
children []*Child
focused string
presets preset.Set
items []paletteItem
}
func newPalette(children []*Child, focused string, presets preset.Set) *paletteState {
p := &paletteState{children: children, focused: focused, presets: presets}
p.rebuild()
return p
}
func (p *paletteState) rebuild() {
all := p.allItems()
q := strings.ToLower(string(p.query))
if q == "" {
p.items = all
} else {
p.items = p.items[:0]
for _, it := range all {
if fuzzyMatch(strings.ToLower(it.label+" "+it.hint), q) {
p.items = append(p.items, it)
}
}
}
if p.cursor >= len(p.items) {
p.cursor = len(p.items) - 1
}
if p.cursor < 0 {
p.cursor = 0
}
}
func (p *paletteState) allItems() []paletteItem {
var out []paletteItem
// Preset commands first — SPEC §4 calls these out as the primary
// way to spawn anything. One entry per file under presets/.
for _, pr := range p.presets.Agents {
out = append(out, paletteItem{
label: "Spawn agent: " + pr.Name,
hint: strings.Join(pr.Argv, " "),
action: paletteAction{kind: "spawn-agent", preset: pr},
})
}
for _, pr := range p.presets.Processes {
out = append(out, paletteItem{
label: "Run process: " + pr.Name,
hint: strings.Join(pr.Argv, " "),
action: paletteAction{kind: "spawn-process", preset: pr},
})
}
// Switch / Kill entries — one per existing child.
for _, c := range p.children {
label := "Switch to " + c.Name
hint := strings.Join(c.Argv, " ")
if c.ID == p.focused {
label = "• " + label + " (current)"
}
if c.Status() != StatusRunning {
label = label + " [" + string(c.Status()) + "]"
}
out = append(out, paletteItem{
label: label,
hint: hint,
action: paletteAction{kind: "switch", childID: c.ID},
})
}
for _, c := range p.children {
if c.Status() != StatusRunning {
continue
}
out = append(out, paletteItem{
label: "Kill " + c.Name,
hint: "SIGTERM " + strings.Join(c.Argv, " "),
action: paletteAction{kind: "kill", childID: c.ID},
})
}
out = append(out, paletteItem{
label: "Quit",
hint: "exit patterm; SIGTERM every child",
action: paletteAction{kind: "quit"},
})
return out
}
func fuzzyMatch(hay, needle string) bool {
if needle == "" {
return true
}
hi := 0
for _, r := range needle {
idx := strings.IndexRune(hay[hi:], r)
if idx < 0 {
return false
}
hi += idx + utf8.RuneLen(r)
}
return true
}
func (p *paletteState) handleKey(b byte, peek []byte) (paletteAction, bool) {
if b == 0x1b {
// Pure Esc cancels; Esc [ A/B is up/down arrow.
if len(peek) >= 2 && peek[0] == '[' {
switch peek[1] {
case 'A':
p.cursor--
if p.cursor < 0 {
p.cursor = 0
}
return paletteAction{}, false
case 'B':
p.cursor++
if p.cursor >= len(p.items) {
p.cursor = len(p.items) - 1
}
return paletteAction{}, false
}
}
return paletteAction{kind: "cancel"}, true
}
switch b {
case '\r', '\n':
if p.cursor >= 0 && p.cursor < len(p.items) {
return p.items[p.cursor].action, true
}
return paletteAction{kind: "cancel"}, true
case 0x7f, 0x08:
if len(p.query) > 0 {
p.query = p.query[:len(p.query)-1]
p.rebuild()
}
case 0x15: // Ctrl-U
p.query = p.query[:0]
p.rebuild()
case 0x0e: // Ctrl-N
p.cursor++
if p.cursor >= len(p.items) {
p.cursor = len(p.items) - 1
}
case 0x10: // Ctrl-P inside palette: cursor up.
p.cursor--
if p.cursor < 0 {
p.cursor = 0
}
case 0x0b: // Ctrl-K inside palette is a no-op (would re-open); ignore.
case 0x16: // Ctrl-V literal-paste — ignore in palette.
default:
if b >= 0x20 && b < 0x7f {
p.query = append(p.query, rune(b))
p.rebuild()
}
}
return paletteAction{}, false
}
// render draws the palette onto out. Geometry: title bar + filter line +
// items + footer, centred. The caller is responsible for the screen
// clear before the first render.
func (p *paletteState) render(out writeFlusher, cols, rows int) {
if cols < 20 {
cols = 20
}
if rows < 6 {
rows = 6
}
width := cols - 4
if width > 80 {
width = 80
}
if width < 40 {
width = cols - 2
}
leftPad := (cols - width) / 2
if leftPad < 1 {
leftPad = 1
}
row := 2
var b strings.Builder
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
moveTo(&b, row, leftPad)
b.WriteString("\x1b[1;7m")
b.WriteString(padRight(" patterm — Ctrl-K", width))
b.WriteString("\x1b[0m")
row++
moveTo(&b, row, leftPad)
b.WriteString("\x1b[7m")
b.WriteString(padRight(" "+string(p.query)+"_", width))
b.WriteString("\x1b[0m")
row++
maxItems := rows - 6
if maxItems > 12 {
maxItems = 12
}
if maxItems < 1 {
maxItems = 1
}
start := 0
if p.cursor >= maxItems {
start = p.cursor - maxItems + 1
}
end := start + maxItems
if end > len(p.items) {
end = len(p.items)
}
for i := start; i < end; i++ {
it := p.items[i]
moveTo(&b, row, leftPad)
if i == p.cursor {
b.WriteString("\x1b[7m")
} else {
b.WriteString("\x1b[0m")
}
line := " " + it.label
if it.hint != "" {
line += " \x1b[2m— " + it.hint + "\x1b[0m"
if i == p.cursor {
line += "\x1b[7m"
}
}
b.WriteString(padRight(line, width+countAnsi(line)))
b.WriteString("\x1b[0m")
row++
}
if len(p.items) == 0 {
moveTo(&b, row, leftPad)
b.WriteString("\x1b[2m no matches\x1b[0m")
row++
}
moveTo(&b, row, leftPad)
b.WriteString("\x1b[2m")
b.WriteString(padRight(" Enter to run · Esc to close · ↑↓ to navigate", width))
b.WriteString("\x1b[0m")
moveTo(&b, 3, leftPad+4+utf8.RuneCountInString(string(p.query)))
b.WriteString("\x1b[?25h")
_, _ = out.Write([]byte(b.String()))
_ = out.Flush()
}
type writeFlusher interface {
Write(p []byte) (int, error)
Flush() error
}
type writeFlusherBase interface {
Write(p []byte) (int, error)
}
type nopFlusher struct{ io writeFlusherBase }
func wrapWriter(w writeFlusherBase) writeFlusher { return nopFlusher{io: w} }
func (n nopFlusher) Write(p []byte) (int, error) { return n.io.Write(p) }
func (n nopFlusher) Flush() error { return nil }
func moveTo(b *strings.Builder, row, col int) {
fmt.Fprintf(b, "\x1b[%d;%dH", row, col)
}
func padRight(s string, width int) string {
w := width - visibleLen(s)
if w <= 0 {
return s
}
return s + strings.Repeat(" ", w)
}
func visibleLen(s string) int {
n := 0
in := false
for _, r := range s {
if r == 0x1b {
in = true
continue
}
if in {
if r == 'm' || r == 'H' {
in = false
}
continue
}
n++
}
return n
}
func countAnsi(s string) int {
return len(s) - visibleLen(s)
}