arguments.go

  1package dialog
  2
  3import (
  4	"cmp"
  5	"strings"
  6
  7	"charm.land/bubbles/v2/help"
  8	"charm.land/bubbles/v2/key"
  9	"charm.land/bubbles/v2/spinner"
 10	"charm.land/bubbles/v2/textinput"
 11	"charm.land/bubbles/v2/viewport"
 12	tea "charm.land/bubbletea/v2"
 13	"charm.land/lipgloss/v2"
 14	"golang.org/x/text/cases"
 15	"golang.org/x/text/language"
 16
 17	"github.com/charmbracelet/crush/internal/commands"
 18	"github.com/charmbracelet/crush/internal/ui/common"
 19	"github.com/charmbracelet/crush/internal/ui/util"
 20	uv "github.com/charmbracelet/ultraviolet"
 21)
 22
 23// ArgumentsID is the identifier for the arguments dialog.
 24const ArgumentsID = "arguments"
 25
 26// Dialog sizing for arguments.
 27const (
 28	maxInputWidth        = 120
 29	minInputWidth        = 30
 30	maxViewportHeight    = 20
 31	argumentsFieldHeight = 3 // label + input + spacing per field
 32)
 33
 34// Arguments represents a dialog for collecting command arguments.
 35type Arguments struct {
 36	com       *common.Common
 37	title     string
 38	arguments []commands.Argument
 39	inputs    []textinput.Model
 40	focused   int
 41	spinner   spinner.Model
 42	loading   bool
 43
 44	description  string
 45	resultAction Action
 46
 47	help   help.Model
 48	keyMap struct {
 49		Confirm,
 50		Next,
 51		Previous,
 52		ScrollUp,
 53		ScrollDown,
 54		Close key.Binding
 55	}
 56
 57	viewport viewport.Model
 58}
 59
 60var _ Dialog = (*Arguments)(nil)
 61
 62// NewArguments creates a new arguments dialog.
 63func NewArguments(com *common.Common, title, description string, arguments []commands.Argument, resultAction Action) *Arguments {
 64	a := &Arguments{
 65		com:          com,
 66		title:        title,
 67		description:  description,
 68		arguments:    arguments,
 69		resultAction: resultAction,
 70	}
 71
 72	a.help = help.New()
 73	a.help.Styles = com.Styles.DialogHelpStyles()
 74
 75	a.keyMap.Confirm = key.NewBinding(
 76		key.WithKeys("enter"),
 77		key.WithHelp("enter", "confirm"),
 78	)
 79	a.keyMap.Next = key.NewBinding(
 80		key.WithKeys("down", "tab"),
 81		key.WithHelp("↓/tab", "next"),
 82	)
 83	a.keyMap.Previous = key.NewBinding(
 84		key.WithKeys("up", "shift+tab"),
 85		key.WithHelp("↑/shift+tab", "previous"),
 86	)
 87	a.keyMap.Close = CloseKey
 88
 89	// Create input fields for each argument.
 90	a.inputs = make([]textinput.Model, len(arguments))
 91	for i, arg := range arguments {
 92		input := textinput.New()
 93		input.SetVirtualCursor(false)
 94		input.SetStyles(com.Styles.TextInput)
 95		input.Prompt = "> "
 96		// Use description as placeholder if available, otherwise title
 97		if arg.Description != "" {
 98			input.Placeholder = arg.Description
 99		} else {
100			input.Placeholder = arg.Title
101		}
102
103		if i == 0 {
104			input.Focus()
105		} else {
106			input.Blur()
107		}
108
109		a.inputs[i] = input
110	}
111	s := spinner.New()
112	s.Spinner = spinner.Dot
113	s.Style = com.Styles.Dialog.Spinner
114	a.spinner = s
115
116	return a
117}
118
119// ID implements Dialog.
120func (a *Arguments) ID() string {
121	return ArgumentsID
122}
123
124// focusInput changes focus to a new input by index with wrap-around.
125func (a *Arguments) focusInput(newIndex int) {
126	a.inputs[a.focused].Blur()
127
128	// Wrap around: Go's modulo can return negative, so add len first.
129	n := len(a.inputs)
130	a.focused = ((newIndex % n) + n) % n
131
132	a.inputs[a.focused].Focus()
133
134	// Ensure the newly focused field is visible in the viewport
135	a.ensureFieldVisible(a.focused)
136}
137
138// isFieldVisible checks if a field at the given index is visible in the viewport.
139func (a *Arguments) isFieldVisible(fieldIndex int) bool {
140	fieldStart := fieldIndex * argumentsFieldHeight
141	fieldEnd := fieldStart + argumentsFieldHeight - 1
142	viewportTop := a.viewport.YOffset()
143	viewportBottom := viewportTop + a.viewport.Height() - 1
144
145	return fieldStart >= viewportTop && fieldEnd <= viewportBottom
146}
147
148// ensureFieldVisible scrolls the viewport to make the field visible.
149func (a *Arguments) ensureFieldVisible(fieldIndex int) {
150	if a.isFieldVisible(fieldIndex) {
151		return
152	}
153
154	fieldStart := fieldIndex * argumentsFieldHeight
155	fieldEnd := fieldStart + argumentsFieldHeight - 1
156	viewportTop := a.viewport.YOffset()
157	viewportHeight := a.viewport.Height()
158
159	// If field is above viewport, scroll up to show it at top
160	if fieldStart < viewportTop {
161		a.viewport.SetYOffset(fieldStart)
162		return
163	}
164
165	// If field is below viewport, scroll down to show it at bottom
166	if fieldEnd > viewportTop+viewportHeight-1 {
167		a.viewport.SetYOffset(fieldEnd - viewportHeight + 1)
168	}
169}
170
171// findVisibleFieldByOffset returns the field index closest to the given viewport offset.
172func (a *Arguments) findVisibleFieldByOffset(fromTop bool) int {
173	offset := a.viewport.YOffset()
174	if !fromTop {
175		offset += a.viewport.Height() - 1
176	}
177
178	fieldIndex := offset / argumentsFieldHeight
179	if fieldIndex >= len(a.inputs) {
180		return len(a.inputs) - 1
181	}
182	return fieldIndex
183}
184
185// HandleMsg implements Dialog.
186func (a *Arguments) HandleMsg(msg tea.Msg) Action {
187	switch msg := msg.(type) {
188	case spinner.TickMsg:
189		if a.loading {
190			var cmd tea.Cmd
191			a.spinner, cmd = a.spinner.Update(msg)
192			return ActionCmd{Cmd: cmd}
193		}
194	case tea.KeyPressMsg:
195		switch {
196		case key.Matches(msg, a.keyMap.Close):
197			return ActionClose{}
198		case key.Matches(msg, a.keyMap.Confirm):
199			// If we're on the last input or there's only one input, submit.
200			if a.focused == len(a.inputs)-1 || len(a.inputs) == 1 {
201				args := make(map[string]string)
202				var warning tea.Cmd
203				for i, arg := range a.arguments {
204					args[arg.ID] = a.inputs[i].Value()
205					if arg.Required && strings.TrimSpace(a.inputs[i].Value()) == "" {
206						warning = util.ReportWarn("Required argument '" + arg.Title + "' is missing.")
207						break
208					}
209				}
210				if warning != nil {
211					return ActionCmd{Cmd: warning}
212				}
213
214				switch action := a.resultAction.(type) {
215				case ActionRunCustomCommand:
216					action.Args = args
217					return action
218				case ActionRunMCPPrompt:
219					action.Args = args
220					return action
221				}
222			}
223			a.focusInput(a.focused + 1)
224		case key.Matches(msg, a.keyMap.Next):
225			a.focusInput(a.focused + 1)
226		case key.Matches(msg, a.keyMap.Previous):
227			a.focusInput(a.focused - 1)
228		default:
229			var cmd tea.Cmd
230			a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg)
231			return ActionCmd{Cmd: cmd}
232		}
233	case tea.MouseWheelMsg:
234		a.viewport, _ = a.viewport.Update(msg)
235		// If focused field scrolled out of view, focus the visible field
236		if !a.isFieldVisible(a.focused) {
237			a.focusInput(a.findVisibleFieldByOffset(msg.Button == tea.MouseWheelDown))
238		}
239	case tea.PasteMsg:
240		var cmd tea.Cmd
241		a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg)
242		return ActionCmd{Cmd: cmd}
243	}
244	return nil
245}
246
247// Cursor returns the cursor position relative to the dialog.
248// we pass the description height to offset the cursor correctly.
249func (a *Arguments) Cursor(descriptionHeight int) *tea.Cursor {
250	cursor := InputCursor(a.com.Styles, a.inputs[a.focused].Cursor())
251	if cursor == nil {
252		return nil
253	}
254	cursor.Y += descriptionHeight + a.focused*argumentsFieldHeight - a.viewport.YOffset() + 1
255	return cursor
256}
257
258// Draw implements Dialog.
259func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
260	s := a.com.Styles
261
262	dialogContentStyle := s.Dialog.Arguments.Content
263	possibleWidth := area.Dx() - s.Dialog.View.GetHorizontalFrameSize() - dialogContentStyle.GetHorizontalFrameSize()
264	// Build fields with label and input.
265	caser := cases.Title(language.English)
266
267	var fields []string
268	for i, arg := range a.arguments {
269		isFocused := i == a.focused
270
271		// Try to pretty up the title for the label.
272		title := strings.ReplaceAll(arg.Title, "_", " ")
273		title = strings.ReplaceAll(title, "-", " ")
274		titleParts := strings.Fields(title)
275		for i, part := range titleParts {
276			titleParts[i] = caser.String(strings.ToLower(part))
277		}
278		labelText := strings.Join(titleParts, " ")
279
280		markRequiredStyle := s.Dialog.Arguments.InputRequiredMarkBlurred
281
282		labelStyle := s.Dialog.Arguments.InputLabelBlurred
283		if isFocused {
284			labelStyle = s.Dialog.Arguments.InputLabelFocused
285			markRequiredStyle = s.Dialog.Arguments.InputRequiredMarkFocused
286		}
287		if arg.Required {
288			labelText += markRequiredStyle.String()
289		}
290		label := labelStyle.Render(labelText)
291
292		labelWidth := lipgloss.Width(labelText)
293		placeholderWidth := lipgloss.Width(a.inputs[i].Placeholder)
294
295		inputWidth := max(placeholderWidth, labelWidth, minInputWidth)
296		inputWidth = min(inputWidth, min(possibleWidth, maxInputWidth))
297		a.inputs[i].SetWidth(inputWidth)
298
299		inputLine := a.inputs[i].View()
300
301		field := lipgloss.JoinVertical(lipgloss.Left, label, inputLine, "")
302		fields = append(fields, field)
303	}
304
305	renderedFields := lipgloss.JoinVertical(lipgloss.Left, fields...)
306
307	// Anchor width to the longest field, capped at maxInputWidth.
308	const scrollbarWidth = 1
309	width := lipgloss.Width(renderedFields)
310	height := lipgloss.Height(renderedFields)
311
312	// Use standard header
313	titleStyle := s.Dialog.Title
314
315	titleText := cmp.Or(a.title, "Arguments")
316
317	header := common.DialogTitle(s, titleText, width, s.Primary, s.Secondary)
318
319	// Add description if available.
320	var description string
321	if a.description != "" {
322		descStyle := s.Dialog.Arguments.Description.Width(width)
323		description = descStyle.Render(a.description)
324	}
325
326	helpView := s.Dialog.HelpView.Width(width).Render(a.help.View(a))
327	if a.loading {
328		helpView = s.Dialog.HelpView.Width(width).Render(a.spinner.View() + " Generating Prompt...")
329	}
330
331	availableHeight := area.Dy() - s.Dialog.View.GetVerticalFrameSize() - dialogContentStyle.GetVerticalFrameSize() - lipgloss.Height(header) - lipgloss.Height(description) - lipgloss.Height(helpView) - 2 // extra spacing
332	viewportHeight := min(height, maxViewportHeight, availableHeight)
333
334	a.viewport.SetWidth(width) // -1 for scrollbar
335	a.viewport.SetHeight(viewportHeight)
336	a.viewport.SetContent(renderedFields)
337
338	scrollbar := common.Scrollbar(s, viewportHeight, a.viewport.TotalLineCount(), viewportHeight, a.viewport.YOffset())
339	content := a.viewport.View()
340	if scrollbar != "" {
341		content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
342	}
343	var contentParts []string
344	if description != "" {
345		contentParts = append(contentParts, description)
346	}
347	contentParts = append(contentParts, content)
348
349	view := lipgloss.JoinVertical(
350		lipgloss.Left,
351		titleStyle.Render(header),
352		dialogContentStyle.Render(lipgloss.JoinVertical(lipgloss.Left, contentParts...)),
353		helpView,
354	)
355
356	dialog := s.Dialog.View.Render(view)
357
358	descriptionHeight := 0
359	if a.description != "" {
360		descriptionHeight = lipgloss.Height(description)
361	}
362	cur := a.Cursor(descriptionHeight)
363
364	DrawCenterCursor(scr, area, dialog, cur)
365	return cur
366}
367
368// StartLoading implements [LoadingDialog].
369func (a *Arguments) StartLoading() tea.Cmd {
370	if a.loading {
371		return nil
372	}
373	a.loading = true
374	return a.spinner.Tick
375}
376
377// StopLoading implements [LoadingDialog].
378func (a *Arguments) StopLoading() {
379	a.loading = false
380}
381
382// ShortHelp implements help.KeyMap.
383func (a *Arguments) ShortHelp() []key.Binding {
384	return []key.Binding{
385		a.keyMap.Confirm,
386		a.keyMap.Next,
387		a.keyMap.Close,
388	}
389}
390
391// FullHelp implements help.KeyMap.
392func (a *Arguments) FullHelp() [][]key.Binding {
393	return [][]key.Binding{
394		{a.keyMap.Confirm, a.keyMap.Next, a.keyMap.Previous},
395		{a.keyMap.Close},
396	}
397}