feat(tui): add bubbletea terminal UI #30
@@ -49,6 +49,10 @@ type absorbSourcesLoadedMsg struct {
|
|||||||
entities []*db.Entity
|
entities []*db.Entity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type stepsPersistedMsg struct{}
|
||||||
|
|
||||||
|
type templateCopiedMsg struct{}
|
||||||
|
|
||||||
type tagsLoadedMsg struct {
|
type tagsLoadedMsg struct {
|
||||||
tags []db.TagCount
|
tags []db.TagCount
|
||||||
}
|
}
|
||||||
@@ -235,3 +239,25 @@ func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd {
|
|||||||
return entityAbsorbedMsg{targetID}
|
return entityAbsorbedMsg{targetID}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func persistSteps(store *db.Store, entityID string, stepsJSON string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
update := db.EntityUpdate{CardData: &stepsJSON}
|
||||||
|
if err := store.Update(entityID, &update); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return stepsPersistedMsg{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if err := clipboard.WriteAll(resolved); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
if err := store.IncrementUse(entityID); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return templateCopiedMsg{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+37
-2
@@ -11,11 +11,22 @@ import (
|
|||||||
"github.com/lerko/nib/internal/display"
|
"github.com/lerko/nib/internal/display"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type detailMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
detailPreview detailMode = iota
|
||||||
|
detailRun
|
||||||
|
detailFill
|
||||||
|
)
|
||||||
|
|
||||||
type detailModel struct {
|
type detailModel struct {
|
||||||
entity *db.Entity
|
entity *db.Entity
|
||||||
scroll int
|
scroll int
|
||||||
height int
|
height int
|
||||||
width int
|
width int
|
||||||
|
mode detailMode
|
||||||
|
run runModel
|
||||||
|
fill fillModel
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDetailModel() detailModel {
|
func newDetailModel() detailModel {
|
||||||
@@ -25,6 +36,7 @@ func newDetailModel() detailModel {
|
|||||||
func (d *detailModel) setEntity(e *db.Entity) {
|
func (d *detailModel) setEntity(e *db.Entity) {
|
||||||
d.entity = e
|
d.entity = e
|
||||||
d.scroll = 0
|
d.scroll = 0
|
||||||
|
d.mode = detailPreview
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *detailModel) setSize(width, height int) {
|
func (d *detailModel) setSize(width, height int) {
|
||||||
@@ -32,7 +44,16 @@ func (d *detailModel) setSize(width, height int) {
|
|||||||
d.height = height
|
d.height = height
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d detailModel) update(msg tea.KeyMsg) detailModel {
|
func (d detailModel) update(msg tea.KeyMsg) (detailModel, tea.Cmd) {
|
||||||
|
switch d.mode {
|
||||||
|
case detailRun:
|
||||||
|
d.run = d.run.update(msg.String())
|
||||||
|
return d, nil
|
||||||
|
case detailFill:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
d.fill, cmd = d.fill.update(msg)
|
||||||
|
return d, cmd
|
||||||
|
default:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
if d.scroll > 0 {
|
if d.scroll > 0 {
|
||||||
@@ -41,10 +62,22 @@ func (d detailModel) update(msg tea.KeyMsg) detailModel {
|
|||||||
case "down", "j":
|
case "down", "j":
|
||||||
d.scroll++
|
d.scroll++
|
||||||
}
|
}
|
||||||
return d
|
return d, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d detailModel) view(width int) string {
|
func (d detailModel) view(width int) string {
|
||||||
|
switch d.mode {
|
||||||
|
case detailRun:
|
||||||
|
return d.run.view(width)
|
||||||
|
case detailFill:
|
||||||
|
return d.fill.view(width)
|
||||||
|
default:
|
||||||
|
return d.previewView(width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d detailModel) previewView(width int) string {
|
||||||
if d.entity == nil {
|
if d.entity == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -154,6 +187,7 @@ func renderCardData(e *db.Entity) string {
|
|||||||
}
|
}
|
||||||
progress := fmt.Sprintf(" %d/%d steps", done, len(steps))
|
progress := fmt.Sprintf(" %d/%d steps", done, len(steps))
|
||||||
b.WriteString(detailLabelStyle.Render(progress))
|
b.WriteString(detailLabelStyle.Render(progress))
|
||||||
|
b.WriteString(" " + helpStyle.Render("r:run"))
|
||||||
|
|
||||||
case db.CardTemplate:
|
case db.CardTemplate:
|
||||||
slots, ok := data["slots"].([]interface{})
|
slots, ok := data["slots"].([]interface{})
|
||||||
@@ -174,6 +208,7 @@ func renderCardData(e *db.Entity) string {
|
|||||||
}
|
}
|
||||||
b.WriteString(line + "\n")
|
b.WriteString(line + "\n")
|
||||||
}
|
}
|
||||||
|
b.WriteString(" " + helpStyle.Render("f:fill"))
|
||||||
|
|
||||||
case db.CardDecision:
|
case db.CardDecision:
|
||||||
if chose, ok := data["chose"].(string); ok && chose != "" {
|
if chose, ok := data["chose"].(string); ok && chose != "" {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -35,6 +35,20 @@ func renderHelp(width, height int) string {
|
|||||||
{"c", "copy to clipboard"},
|
{"c", "copy to clipboard"},
|
||||||
{"e", "edit in $EDITOR"},
|
{"e", "edit in $EDITOR"},
|
||||||
{"!", "toggle pin"},
|
{"!", "toggle pin"},
|
||||||
|
{"r", "run checklist"},
|
||||||
|
{"f", "fill template"},
|
||||||
|
}},
|
||||||
|
{"Run Mode", [][2]string{
|
||||||
|
{"j/k", "move between steps"},
|
||||||
|
{"space", "toggle step"},
|
||||||
|
{"r", "reset all steps"},
|
||||||
|
{"esc", "save + exit"},
|
||||||
|
}},
|
||||||
|
{"Fill Mode", [][2]string{
|
||||||
|
{"tab", "next slot"},
|
||||||
|
{"shift+tab", "prev slot"},
|
||||||
|
{"enter", "copy resolved"},
|
||||||
|
{"esc", "cancel"},
|
||||||
}},
|
}},
|
||||||
{"Global", [][2]string{
|
{"Global", [][2]string{
|
||||||
{"?", "toggle help"},
|
{"?", "toggle help"},
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ type keyMap struct {
|
|||||||
Sort key.Binding
|
Sort key.Binding
|
||||||
Intent key.Binding
|
Intent key.Binding
|
||||||
Absorb key.Binding
|
Absorb key.Binding
|
||||||
|
Run key.Binding
|
||||||
|
Fill key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
var keys = keyMap{
|
var keys = keyMap{
|
||||||
@@ -54,4 +56,6 @@ var keys = keyMap{
|
|||||||
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
|
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
|
||||||
Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")),
|
Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")),
|
||||||
Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")),
|
Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")),
|
||||||
|
Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")),
|
||||||
|
Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")),
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-11
@@ -216,6 +216,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.state = stateAbsorb
|
m.state = stateAbsorb
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case stepsPersistedMsg:
|
||||||
|
m.status = "steps saved"
|
||||||
|
m.detail.mode = detailPreview
|
||||||
|
return m, m.reloadDetail(m.detail.entity.ID)
|
||||||
|
|
||||||
|
case templateCopiedMsg:
|
||||||
|
m.status = "copied resolved"
|
||||||
|
m.detail.mode = detailPreview
|
||||||
|
return m, loadEntities(m.store, m.listParams())
|
||||||
|
|
||||||
case tagsLoadedMsg:
|
case tagsLoadedMsg:
|
||||||
m.filter.setTags(msg.tags)
|
m.filter.setTags(msg.tags)
|
||||||
m.state = stateTagFilter
|
m.state = stateTagFilter
|
||||||
@@ -328,6 +338,18 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case "esc":
|
case "esc":
|
||||||
if m.state == stateDetail {
|
if m.state == stateDetail {
|
||||||
|
if m.detail.mode == detailRun {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
if m.detail.run.dirty {
|
||||||
|
cmd = persistSteps(m.store, m.detail.run.entityID, m.detail.run.stepsJSON())
|
||||||
|
}
|
||||||
|
m.detail.mode = detailPreview
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
if m.detail.mode == detailFill {
|
||||||
|
m.detail.mode = detailPreview
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
m.state = stateList
|
m.state = stateList
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -349,15 +371,6 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "enter":
|
|
||||||
if m.state == stateList {
|
|
||||||
if e := m.selectedEntity(); e != nil {
|
|
||||||
m.detail.setEntity(e)
|
|
||||||
m.state = stateDetail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case "d":
|
case "d":
|
||||||
if m.state == stateList {
|
if m.state == stateList {
|
||||||
if e := m.selectedEntity(); e != nil {
|
if e := m.selectedEntity(); e != nil {
|
||||||
@@ -435,10 +448,44 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "e":
|
case "e":
|
||||||
if m.state == stateDetail && m.detail.entity != nil {
|
if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview {
|
||||||
return m, editInEditor(m.store, m.detail.entity)
|
return m, editInEditor(m.store, m.detail.entity)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case "r":
|
||||||
|
if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview {
|
||||||
|
if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardChecklist {
|
||||||
|
m.detail.run = newRunModel(m.detail.entity.ID, m.detail.entity.CardData)
|
||||||
|
m.detail.mode = detailRun
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case "f":
|
||||||
|
if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview {
|
||||||
|
if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardTemplate {
|
||||||
|
m.detail.fill = newFillModel(m.detail.entity.ID, m.detail.entity.Body)
|
||||||
|
m.detail.mode = detailFill
|
||||||
|
return m, m.detail.fill.ti.Focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case "enter":
|
||||||
|
if m.state == stateDetail && m.detail.mode == detailFill {
|
||||||
|
m.detail.fill.commitActive()
|
||||||
|
resolved := m.detail.fill.resolve()
|
||||||
|
return m, copyResolved(m.store, m.detail.fill.entityID, resolved)
|
||||||
|
}
|
||||||
|
if m.state == stateList {
|
||||||
|
if e := m.selectedEntity(); e != nil {
|
||||||
|
m.detail.setEntity(e)
|
||||||
|
m.state = stateDetail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch m.state {
|
switch m.state {
|
||||||
@@ -449,7 +496,9 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.list = m.list.update(msg)
|
m.list = m.list.update(msg)
|
||||||
}
|
}
|
||||||
case stateDetail:
|
case stateDetail:
|
||||||
m.detail = m.detail.update(msg)
|
var cmd tea.Cmd
|
||||||
|
m.detail, cmd = m.detail.update(msg)
|
||||||
|
return m, cmd
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type runStep struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type runModel struct {
|
||||||
|
steps []runStep
|
||||||
|
cursor int
|
||||||
|
entityID string
|
||||||
|
dirty bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRunModel(entityID string, cardData *string) runModel {
|
||||||
|
m := runModel{entityID: entityID}
|
||||||
|
m.steps = parseChecklist(cardData)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseChecklist(cardData *string) []runStep {
|
||||||
|
if cardData == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var data struct {
|
||||||
|
Steps []runStep `json:"steps"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(*cardData), &data); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data.Steps
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r runModel) stepsJSON() string {
|
||||||
|
b, _ := json.Marshal(map[string]any{"steps": r.steps})
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r runModel) doneCount() int {
|
||||||
|
n := 0
|
||||||
|
for _, s := range r.steps {
|
||||||
|
if s.Done {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r runModel) update(key string) runModel {
|
||||||
|
switch key {
|
||||||
|
case "up", "k":
|
||||||
|
if r.cursor > 0 {
|
||||||
|
r.cursor--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if r.cursor < len(r.steps)-1 {
|
||||||
|
r.cursor++
|
||||||
|
}
|
||||||
|
case " ":
|
||||||
|
if r.cursor < len(r.steps) {
|
||||||
|
r.steps[r.cursor].Done = !r.steps[r.cursor].Done
|
||||||
|
r.dirty = true
|
||||||
|
}
|
||||||
|
case "r":
|
||||||
|
for i := range r.steps {
|
||||||
|
r.steps[i].Done = false
|
||||||
|
}
|
||||||
|
r.dirty = true
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r runModel) view(width int) string {
|
||||||
|
if len(r.steps) == 0 {
|
||||||
|
return statusStyle.Render("no steps")
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
done := r.doneCount()
|
||||||
|
total := len(r.steps)
|
||||||
|
pct := 0
|
||||||
|
if total > 0 {
|
||||||
|
pct = done * 100 / total
|
||||||
|
}
|
||||||
|
|
||||||
|
header := fmt.Sprintf("▶ running %d/%d done (%d%%)", done, total, pct)
|
||||||
|
b.WriteString(detailHeaderStyle.Render(header))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
for i, step := range r.steps {
|
||||||
|
var line string
|
||||||
|
if step.Done {
|
||||||
|
line = checkDoneStyle.Render("[✓] " + step.Text)
|
||||||
|
} else {
|
||||||
|
line = checkPendingStyle.Render("[ ] " + step.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == r.cursor {
|
||||||
|
b.WriteString(selectedItemStyle.Render(" " + line))
|
||||||
|
} else {
|
||||||
|
b.WriteString(listItemStyle.Render(line))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(helpStyle.Render("space:toggle r:reset esc:save+exit"))
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -38,7 +38,14 @@ func countText(m model) string {
|
|||||||
func contextHints(m model) string {
|
func contextHints(m model) string {
|
||||||
switch m.state {
|
switch m.state {
|
||||||
case stateDetail:
|
case stateDetail:
|
||||||
return "p:promote D:demote c:copy e:edit !:pin esc:back"
|
switch m.detail.mode {
|
||||||
|
case detailRun:
|
||||||
|
return "space:toggle j/k:nav r:reset esc:save+exit"
|
||||||
|
case detailFill:
|
||||||
|
return "tab:next shift+tab:prev enter:copy esc:cancel"
|
||||||
|
default:
|
||||||
|
return "p:promote D:demote c:copy e:edit r:run f:fill !:pin esc:back"
|
||||||
|
}
|
||||||
case stateInput:
|
case stateInput:
|
||||||
return "enter:submit esc:cancel"
|
return "enter:submit esc:cancel"
|
||||||
case stateTagFilter:
|
case stateTagFilter:
|
||||||
|
|||||||
Reference in New Issue
Block a user