Added ui:prompt-keypress to support single key presses
All checks were successful
ci / build (push) Successful in 3m41s

Have also fixed a bug in ui:prompt which was keeping the script running when the prompt was being cancelled
This commit is contained in:
Leon Mika 2025-10-26 07:34:14 +11:00
parent 022cec7393
commit 8dafa6fa8f
5 changed files with 122 additions and 18 deletions

View file

@ -2,6 +2,7 @@ package cmdpacks
import ( import (
"context" "context"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/commandctrl" "lmika.dev/cmd/dynamo-browse/internal/common/ui/commandctrl"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/events" "lmika.dev/cmd/dynamo-browse/internal/common/ui/events"
@ -38,16 +39,26 @@ func (m *uiModule) uiPrompt(ctx context.Context, args ucl.CallArgs) (any, error)
} }
resChan := make(chan string) resChan := make(chan string)
cancelChan := make(chan struct{})
go func() { go func() {
commandctrl.PostMsg(ctx, events.PromptForInput(prompt, nil, func(value string) tea.Msg { commandctrl.PostMsg(ctx, events.PromptForInputMsg{
resChan <- value Prompt: prompt,
return nil OnDone: func(value string) tea.Msg {
})) resChan <- value
return nil
},
OnCancel: func() tea.Msg {
cancelChan <- struct{}{}
return nil
},
})
}() }()
select { select {
case value := <-resChan: case value := <-resChan:
return value, nil return value, nil
case <-cancelChan:
return nil, nil
case <-ctx.Done(): case <-ctx.Done():
return nil, ctx.Err() return nil, ctx.Err()
} }
@ -75,6 +86,38 @@ func (m *uiModule) uiConfirm(ctx context.Context, args ucl.CallArgs) (any, error
} }
} }
func (m *uiModule) uiInKey(ctx context.Context, args ucl.CallArgs) (any, error) {
var prompt string
if err := args.Bind(&prompt); err != nil {
return nil, err
}
resChan := make(chan string)
cancelChan := make(chan struct{})
go func() {
commandctrl.PostMsg(ctx, events.PromptForKeyMsg{
Prompt: prompt,
OnDone: func(value string) tea.Msg {
resChan <- value
return nil
},
OnCancel: func() tea.Msg {
cancelChan <- struct{}{}
return nil
},
})
}()
select {
case value := <-resChan:
return value, nil
case <-cancelChan:
return nil, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (m *uiModule) uiPromptTable(ctx context.Context, args ucl.CallArgs) (any, error) { func (m *uiModule) uiPromptTable(ctx context.Context, args ucl.CallArgs) (any, error) {
tables, err := m.tableService.ListTables(context.Background()) tables, err := m.tableService.ListTables(context.Background())
if err != nil { if err != nil {
@ -166,13 +209,14 @@ func moduleUI(
return ucl.Module{ return ucl.Module{
Name: "ui", Name: "ui",
Builtins: map[string]ucl.BuiltinHandler{ Builtins: map[string]ucl.BuiltinHandler{
"command": m.uiCommand, "command": m.uiCommand,
"prompt": m.uiPrompt, "prompt": m.uiPrompt,
"prompt-table": m.uiPromptTable, "prompt-table": m.uiPromptTable,
"confirm": m.uiConfirm, "prompt-keypress": m.uiInKey,
"query": m.uiQuery, "confirm": m.uiConfirm,
"filter": m.uiFilter, "query": m.uiQuery,
"bind": m.uiBind, "filter": m.uiFilter,
"bind": m.uiBind,
}, },
}, m.ckb }, m.ckb
} }

View file

@ -1,9 +1,10 @@
package events package events
import ( import (
"log"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services" "lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services"
"log"
) )
func Error(err error) tea.Msg { func Error(err error) tea.Msg {
@ -31,10 +32,23 @@ func PromptForInput(prompt string, history services.HistoryProvider, onDone func
} }
} }
func PromptForKey(prompt string, onDone func(key string) tea.Msg) tea.Msg {
return PromptForKeyMsg{
Prompt: prompt,
OnDone: onDone,
}
}
func Confirm(prompt string, onResult func(yes bool) tea.Msg) tea.Msg { func Confirm(prompt string, onResult func(yes bool) tea.Msg) tea.Msg {
return PromptForInput(prompt, nil, func(value string) tea.Msg { return PromptForInputMsg{
return onResult(value == "y") Prompt: prompt,
}) OnDone: func(value string) tea.Msg {
return onResult(value == "y")
},
OnCancel: func() tea.Msg {
return onResult(false)
},
}
} }
func ConfirmYes(prompt string, onYes func() tea.Msg) tea.Msg { func ConfirmYes(prompt string, onYes func() tea.Msg) tea.Msg {

View file

@ -27,3 +27,10 @@ type PromptForInputMsg struct {
OnCancel func() tea.Msg OnCancel func() tea.Msg
OnTabComplete func(value string) (string, bool) OnTabComplete func(value string) (string, bool)
} }
// PromptForKey indicates that the context is requesting a single key press
type PromptForKeyMsg struct {
Prompt string
OnDone func(key string) tea.Msg
OnCancel func() tea.Msg
}

View file

@ -1,6 +1,8 @@
package statusandprompt package statusandprompt
import ( import (
"strings"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@ -9,7 +11,6 @@ import (
"lmika.dev/cmd/dynamo-browse/internal/common/ui/events" "lmika.dev/cmd/dynamo-browse/internal/common/ui/events"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/layout" "lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/layout"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/utils" "lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/utils"
"strings"
) )
// StatusAndPrompt is a resizing model which displays a submodel and a status bar. When the start prompt // StatusAndPrompt is a resizing model which displays a submodel and a status bar. When the start prompt
@ -24,6 +25,7 @@ type StatusAndPrompt struct {
spinner spinner.Model spinner spinner.Model
spinnerVisible bool spinnerVisible bool
pendingInput *pendingInputState pendingInput *pendingInputState
pendingKeyState *pendingKeyState
textInput textinput.Model textInput textinput.Model
width, height int width, height int
lastModeLineHeight int lastModeLineHeight int
@ -85,7 +87,7 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
s.statusMessage = msg.StatusMessage() s.statusMessage = msg.StatusMessage()
case events.PromptForInputMsg: case events.PromptForInputMsg:
if s.pendingInput != nil { if s.pendingInput != nil || s.pendingKeyState != nil {
// ignore, already in an input // ignore, already in an input
return s, nil return s, nil
} }
@ -94,7 +96,40 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.textInput.Focus() s.textInput.Focus()
s.textInput.SetValue("") s.textInput.SetValue("")
s.pendingInput = newPendingInputState(msg) s.pendingInput = newPendingInputState(msg)
case events.PromptForKeyMsg:
if s.pendingInput != nil || s.pendingKeyState != nil {
// ignore, already in an input
return s, nil
}
s.statusMessage = msg.Prompt
s.pendingKeyState = &pendingKeyState{msg}
case tea.KeyMsg: case tea.KeyMsg:
if s.pendingKeyState != nil {
switch msg.Type {
case tea.KeyCtrlC, tea.KeyEsc:
if s.pendingKeyState.originalMsg.OnCancel != nil {
pendingKeyState := s.pendingKeyState
cc.Add(func() tea.Msg {
m := pendingKeyState.originalMsg.OnCancel()
return m
})
}
s.pendingKeyState = nil
default:
if s.pendingKeyState.originalMsg.OnDone != nil {
pendingKeyState := s.pendingKeyState
cc.Add(func() tea.Msg {
m := pendingKeyState.originalMsg.OnDone(msg.String())
return m
})
}
s.pendingKeyState = nil
}
s.statusMessage = ""
return s, cc.Cmd()
}
if s.pendingInput != nil { if s.pendingInput != nil {
switch msg.Type { switch msg.Type {
case tea.KeyCtrlC, tea.KeyEsc: case tea.KeyCtrlC, tea.KeyEsc:
@ -187,7 +222,7 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (s *StatusAndPrompt) InPrompt() bool { func (s *StatusAndPrompt) InPrompt() bool {
return s.pendingInput != nil return s.pendingInput != nil || s.pendingKeyState != nil
} }
func (s *StatusAndPrompt) View() string { func (s *StatusAndPrompt) View() string {

View file

@ -14,3 +14,7 @@ func newPendingInputState(msg events.PromptForInputMsg) *pendingInputState {
type PasteboardProvider interface { type PasteboardProvider interface {
ReadText() (string, bool) ReadText() (string, bool)
} }
type pendingKeyState struct {
originalMsg events.PromptForKeyMsg
}