arguments.go

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