333 lines
7.1 KiB
Go
333 lines
7.1 KiB
Go
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)
|
||
}
|