feat(tui): add interactive run mode for checklists and fill mode for templates
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.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user