77222ff1b8
Run mode (r key on checklist cards): cursor navigates steps, space toggles done/undone, r resets all, esc saves changes to DB and exits. Persists step state — improvement over web which discards on exit. Fill mode (f key on template cards): tab/shift-tab navigates slots, type to fill values, enter resolves template and copies to clipboard with use count increment. Esc cancels without copying. Both modes are sub-states of detail view, keeping architecture simple.
153 lines
3.0 KiB
Go
153 lines
3.0 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"github.com/lerko/nib/internal/carddata"
|
|
)
|
|
|
|
type fillSlot struct {
|
|
Name string
|
|
Default string
|
|
Value string
|
|
}
|
|
|
|
type fillModel struct {
|
|
slots []fillSlot
|
|
active int
|
|
body string
|
|
entityID string
|
|
ti textinput.Model
|
|
}
|
|
|
|
func newFillModel(entityID, body string) fillModel {
|
|
slots := discoverSlots(body)
|
|
m := fillModel{
|
|
slots: slots,
|
|
body: body,
|
|
entityID: entityID,
|
|
}
|
|
m.ti = textinput.New()
|
|
m.ti.CharLimit = 200
|
|
if len(slots) > 0 {
|
|
m.ti.Placeholder = slots[0].Name
|
|
m.ti.Focus()
|
|
if slots[0].Default != "" {
|
|
m.ti.SetValue(slots[0].Default)
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func discoverSlots(body string) []fillSlot {
|
|
matches := carddata.TemplateSlotRe.FindAllStringSubmatch(body, -1)
|
|
seen := map[string]bool{}
|
|
var slots []fillSlot
|
|
for _, m := range matches {
|
|
name := m[1]
|
|
if !seen[name] {
|
|
seen[name] = true
|
|
slots = append(slots, fillSlot{Name: name})
|
|
}
|
|
}
|
|
return slots
|
|
}
|
|
|
|
func (f fillModel) resolve() string {
|
|
result := f.body
|
|
for _, s := range f.slots {
|
|
val := s.Value
|
|
if val == "" {
|
|
val = "${" + s.Name + "}"
|
|
}
|
|
result = strings.ReplaceAll(result, "${"+s.Name+"}", val)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (f fillModel) update(msg tea.KeyMsg) (fillModel, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "tab":
|
|
f.commitActive()
|
|
if f.active < len(f.slots)-1 {
|
|
f.active++
|
|
} else {
|
|
f.active = 0
|
|
}
|
|
f.loadActive()
|
|
return f, nil
|
|
case "shift+tab":
|
|
f.commitActive()
|
|
if f.active > 0 {
|
|
f.active--
|
|
} else {
|
|
f.active = len(f.slots) - 1
|
|
}
|
|
f.loadActive()
|
|
return f, nil
|
|
}
|
|
|
|
f.ti, _ = f.ti.Update(msg)
|
|
return f, nil
|
|
}
|
|
|
|
func (f *fillModel) commitActive() {
|
|
if f.active < len(f.slots) {
|
|
f.slots[f.active].Value = f.ti.Value()
|
|
}
|
|
}
|
|
|
|
func (f *fillModel) loadActive() {
|
|
if f.active < len(f.slots) {
|
|
s := f.slots[f.active]
|
|
f.ti.SetValue(s.Value)
|
|
f.ti.Placeholder = s.Name
|
|
f.ti.Focus()
|
|
}
|
|
}
|
|
|
|
func (f fillModel) view(width int) string {
|
|
if len(f.slots) == 0 {
|
|
return statusStyle.Render("no slots")
|
|
}
|
|
|
|
var b strings.Builder
|
|
header := fmt.Sprintf("⤓ filling slot %d/%d", f.active+1, len(f.slots))
|
|
b.WriteString(detailHeaderStyle.Render(header))
|
|
b.WriteString("\n\n")
|
|
|
|
for i, slot := range f.slots {
|
|
name := detailLabelStyle.Render(slot.Name)
|
|
var val string
|
|
if i == f.active {
|
|
val = f.ti.View()
|
|
} else if slot.Value != "" {
|
|
val = detailValueStyle.Render(slot.Value)
|
|
} else {
|
|
val = idStyle.Render("(empty)")
|
|
}
|
|
|
|
if i == f.active {
|
|
b.WriteString(selectedItemStyle.Render(" " + name + " " + val))
|
|
} else {
|
|
b.WriteString(listItemStyle.Render(name + " " + val))
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
preview := f.resolve()
|
|
if len(preview) > width-4 {
|
|
preview = preview[:width-7] + "…"
|
|
}
|
|
b.WriteString(detailBodyStyle.Render(preview))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(helpStyle.Render("tab:next shift+tab:prev enter:copy esc:cancel"))
|
|
|
|
return b.String()
|
|
}
|