diff --git a/internal/common/ui/commandctrl/cmdpacks/modui.go b/internal/common/ui/commandctrl/cmdpacks/modui.go index 2ef4e7e..8075639 100644 --- a/internal/common/ui/commandctrl/cmdpacks/modui.go +++ b/internal/common/ui/commandctrl/cmdpacks/modui.go @@ -2,6 +2,7 @@ package cmdpacks import ( "context" + tea "github.com/charmbracelet/bubbletea" "lmika.dev/cmd/dynamo-browse/internal/common/ui/commandctrl" "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) + cancelChan := make(chan struct{}) go func() { - commandctrl.PostMsg(ctx, events.PromptForInput(prompt, nil, func(value string) tea.Msg { - resChan <- value - return nil - })) + commandctrl.PostMsg(ctx, events.PromptForInputMsg{ + 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() } @@ -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) { tables, err := m.tableService.ListTables(context.Background()) if err != nil { @@ -166,13 +209,14 @@ func moduleUI( return ucl.Module{ Name: "ui", Builtins: map[string]ucl.BuiltinHandler{ - "command": m.uiCommand, - "prompt": m.uiPrompt, - "prompt-table": m.uiPromptTable, - "confirm": m.uiConfirm, - "query": m.uiQuery, - "filter": m.uiFilter, - "bind": m.uiBind, + "command": m.uiCommand, + "prompt": m.uiPrompt, + "prompt-table": m.uiPromptTable, + "prompt-keypress": m.uiInKey, + "confirm": m.uiConfirm, + "query": m.uiQuery, + "filter": m.uiFilter, + "bind": m.uiBind, }, }, m.ckb } diff --git a/internal/common/ui/events/commands.go b/internal/common/ui/events/commands.go index c8f32ca..aef48b2 100644 --- a/internal/common/ui/events/commands.go +++ b/internal/common/ui/events/commands.go @@ -1,9 +1,10 @@ package events import ( + "log" + tea "github.com/charmbracelet/bubbletea" "lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services" - "log" ) 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 { - return PromptForInput(prompt, nil, func(value string) tea.Msg { - return onResult(value == "y") - }) + return PromptForInputMsg{ + 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 { diff --git a/internal/common/ui/events/errors.go b/internal/common/ui/events/errors.go index 591256d..f84316e 100644 --- a/internal/common/ui/events/errors.go +++ b/internal/common/ui/events/errors.go @@ -27,3 +27,10 @@ type PromptForInputMsg struct { OnCancel func() tea.Msg 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 +} diff --git a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go index 2f542e3..d629ce4 100644 --- a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go +++ b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go @@ -1,6 +1,8 @@ package statusandprompt import ( + "strings" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" 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/dynamo-browse/ui/teamodels/layout" "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 @@ -24,6 +25,7 @@ type StatusAndPrompt struct { spinner spinner.Model spinnerVisible bool pendingInput *pendingInputState + pendingKeyState *pendingKeyState textInput textinput.Model width, height int lastModeLineHeight int @@ -85,7 +87,7 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } s.statusMessage = msg.StatusMessage() case events.PromptForInputMsg: - if s.pendingInput != nil { + if s.pendingInput != nil || s.pendingKeyState != nil { // ignore, already in an input return s, nil } @@ -94,7 +96,40 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.textInput.Focus() s.textInput.SetValue("") 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: + 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 { switch msg.Type { 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 { - return s.pendingInput != nil + return s.pendingInput != nil || s.pendingKeyState != nil } func (s *StatusAndPrompt) View() string { diff --git a/internal/dynamo-browse/ui/teamodels/statusandprompt/types.go b/internal/dynamo-browse/ui/teamodels/statusandprompt/types.go index b609451..02ff0bf 100644 --- a/internal/dynamo-browse/ui/teamodels/statusandprompt/types.go +++ b/internal/dynamo-browse/ui/teamodels/statusandprompt/types.go @@ -14,3 +14,7 @@ func newPendingInputState(msg events.PromptForInputMsg) *pendingInputState { type PasteboardProvider interface { ReadText() (string, bool) } + +type pendingKeyState struct { + originalMsg events.PromptForKeyMsg +}