package cview import ( "sync" "github.com/gdamore/tcell/v2" ) // DefaultFormFieldWidth is the default field screen width of form elements // whose field width is flexible (0). This is used in the Form class for // horizontal layouts. var DefaultFormFieldWidth = 10 // FormItem is the interface all form items must implement to be able to be // included in a form. type FormItem interface { Primitive // GetLabel returns the item's label text. GetLabel() string // SetFormAttributes sets a number of item attributes at once. SetFormAttributes(labelWidth int, bgColor, labelColor, labelColorFocused, fieldTextColor, fieldTextColorFocused, fieldBgColor, fieldBgColorFocused tcell.Color) FormItem // GetFieldWidth returns the width of the form item's field (the area which // is manipulated by the user) in number of screen cells. A value of 0 // indicates the the field width is flexible and may use as much space as // required. GetFieldWidth() int // GetFieldHeight returns the height of the form item. GetFieldHeight() int // SetFinishedFunc sets the handler function for when the user finished // entering data into the item. The handler may receive events for the // Enter key (we're done), the Escape key (cancel input), the Tab key (move to // next field), and the Backtab key (move to previous field). SetFinishedFunc(handler func(key tcell.Key)) FormItem } // Form allows you to combine multiple one-line form elements into a vertical // or horizontal layout. Form elements include types such as InputField or // CheckBox. These elements can be optionally followed by one or more buttons // for which you can define form-wide actions (e.g. Save, Clear, Cancel). type Form struct { *Box // The items of the form (one row per item). items []FormItem // The buttons of the form. buttons []*Button // If set to true, instead of position items and buttons from top to bottom, // they are positioned from left to right. horizontal bool // The alignment of the buttons. buttonsAlign int // The number of empty rows between items. itemPadding int // The index of the item or button which has focus. (Items are counted first, // buttons are counted last.) This is only used when the form itself receives // focus so that the last element that had focus keeps it. focusedElement int // Whether or not navigating the form will wrap around. wrapAround bool // The label color. labelColor tcell.Color // The label color when focused. labelColorFocused tcell.Color // The background color of the input area. fieldBackgroundColor tcell.Color // The background color of the input area when focused. fieldBackgroundColorFocused tcell.Color // The text color of the input area. fieldTextColor tcell.Color // The text color of the input area when focused. fieldTextColorFocused tcell.Color // The background color of the buttons. buttonBackgroundColor tcell.Color // The background color of the buttons when focused. buttonBackgroundColorFocused tcell.Color // The color of the button text. buttonTextColor tcell.Color // The color of the button text when focused. buttonTextColorFocused tcell.Color // An optional function which is called when the user hits Escape. cancel func() sync.RWMutex } // NewForm returns a new form. func NewForm() *Form { box := NewBox().SetBorderPadding(1, 1, 1, 1) f := &Form{ Box: box, itemPadding: 1, labelColor: Styles.SecondaryTextColor, labelColorFocused: Styles.SecondaryTextColor, fieldBackgroundColor: Styles.ContrastBackgroundColor, fieldBackgroundColorFocused: Styles.ContrastBackgroundColor, fieldTextColor: Styles.PrimaryTextColor, fieldTextColorFocused: Styles.PrimaryTextColor, buttonBackgroundColor: Styles.ContrastBackgroundColor, buttonBackgroundColorFocused: Styles.PrimaryTextColor, buttonTextColor: Styles.PrimaryTextColor, buttonTextColorFocused: Styles.ContrastBackgroundColor, } f.focus = f return f } // SetItemPadding sets the number of empty rows between form items for vertical // layouts and the number of empty cells between form items for horizontal // layouts. func (f *Form) SetItemPadding(padding int) *Form { f.Lock() defer f.Unlock() f.itemPadding = padding return f } // SetHorizontal sets the direction the form elements are laid out. If set to // true, instead of positioning them from top to bottom (the default), they are // positioned from left to right, moving into the next row if there is not // enough space. func (f *Form) SetHorizontal(horizontal bool) *Form { f.Lock() defer f.Unlock() f.horizontal = horizontal return f } // SetLabelColor sets the color of the labels. func (f *Form) SetLabelColor(color tcell.Color) *Form { f.Lock() defer f.Unlock() f.labelColor = color return f } // SetLabelColorFocused sets the color of the labels when focused. func (f *Form) SetLabelColorFocused(color tcell.Color) *Form { f.Lock() defer f.Unlock() f.labelColorFocused = color return f } // SetFieldBackgroundColor sets the background color of the input areas. func (f *Form) SetFieldBackgroundColor(color tcell.Color) *Form { f.Lock() defer f.Unlock() f.fieldBackgroundColor = color return f } // SetFieldBackgroundColorFocused sets the background color of the input areas when focused. func (f *Form) SetFieldBackgroundColorFocused(color tcell.Color) *Form { f.Lock() defer f.Unlock() f.fieldBackgroundColorFocused = color return f } // SetFieldTextColor sets the text color of the input areas. func (f *Form) SetFieldTextColor(color tcell.Color) *Form { f.Lock() defer f.Unlock() f.fieldTextColor = color return f } // SetFieldTextColorFocused sets the text color of the input areas when focused. func (f *Form) SetFieldTextColorFocused(color tcell.Color) *Form { f.Lock() defer f.Unlock() f.fieldTextColorFocused = color return f } // SetButtonsAlign sets how the buttons align horizontally, one of AlignLeft // (the default), AlignCenter, and AlignRight. This is only func (f *Form) SetButtonsAlign(align int) *Form { f.Lock() defer f.Unlock() f.buttonsAlign = align return f } // SetButtonBackgroundColor sets the background color of the buttons. func (f *Form) SetButtonBackgroundColor(color tcell.Color) *Form { f.Lock() defer f.Unlock() f.buttonBackgroundColor = color return f } // SetButtonBackgroundColorFocused sets the background color of the buttons when focused. func (f *Form) SetButtonBackgroundColorFocused(color tcell.Color) *Form { f.Lock() defer f.Unlock() f.buttonBackgroundColorFocused = color return f } // SetButtonTextColor sets the color of the button texts. func (f *Form) SetButtonTextColor(color tcell.Color) *Form { f.Lock() defer f.Unlock() f.buttonTextColor = color return f } // SetButtonTextColorFocused sets the color of the button texts when focused. func (f *Form) SetButtonTextColorFocused(color tcell.Color) *Form { f.Lock() defer f.Unlock() f.buttonTextColorFocused = color return f } // SetFocus shifts the focus to the form element with the given index, counting // non-button items first and buttons last. Note that this index is only used // when the form itself receives focus. func (f *Form) SetFocus(index int) *Form { f.Lock() defer f.Unlock() if index < 0 { f.focusedElement = 0 } else if index >= len(f.items)+len(f.buttons) { f.focusedElement = len(f.items) + len(f.buttons) } else { f.focusedElement = index } return f } // AddInputField adds an input field to the form. It has a label, an optional // initial value, a field width (a value of 0 extends it as far as possible), // an optional accept function to validate the item's value (set to nil to // accept any text), and an (optional) callback function which is invoked when // the input field's text has changed. func (f *Form) AddInputField(label, value string, fieldWidth int, accept func(textToCheck string, lastChar rune) bool, changed func(text string)) *Form { f.Lock() defer f.Unlock() f.items = append(f.items, NewInputField(). SetLabel(label). SetText(value). SetFieldWidth(fieldWidth). SetAcceptanceFunc(accept). SetChangedFunc(changed)) return f } // AddPasswordField adds a password field to the form. This is similar to an // input field except that the user's input not shown. Instead, a "mask" // character is displayed. The password field has a label, an optional initial // value, a field width (a value of 0 extends it as far as possible), and an // (optional) callback function which is invoked when the input field's text has // changed. func (f *Form) AddPasswordField(label, value string, fieldWidth int, mask rune, changed func(text string)) *Form { f.Lock() defer f.Unlock() if mask == 0 { mask = '*' } f.items = append(f.items, NewInputField(). SetLabel(label). SetText(value). SetFieldWidth(fieldWidth). SetMaskCharacter(mask). SetChangedFunc(changed)) return f } // AddDropDownSimple adds a drop-down element to the form. It has a label, options, // and an (optional) callback function which is invoked when an option was // selected. The initial option may be a negative value to indicate that no // option is currently selected. func (f *Form) AddDropDownSimple(label string, initialOption int, selected func(index int, option *DropDownOption), options ...string) *Form { f.Lock() defer f.Unlock() f.items = append(f.items, NewDropDown(). SetLabel(label). SetOptionsSimple(selected, options...). SetCurrentOption(initialOption)) return f } // AddDropDown adds a drop-down element to the form. It has a label, options, // and an (optional) callback function which is invoked when an option was // selected. The initial option may be a negative value to indicate that no // option is currently selected. func (f *Form) AddDropDown(label string, initialOption int, selected func(index int, option *DropDownOption), options []*DropDownOption) *Form { f.Lock() defer f.Unlock() f.items = append(f.items, NewDropDown(). SetLabel(label). SetOptions(selected, options...). SetCurrentOption(initialOption)) return f } // AddCheckBox adds a checkbox to the form. It has a label, a message, an // initial state, and an (optional) callback function which is invoked when the // state of the checkbox was changed by the user. func (f *Form) AddCheckBox(label string, message string, checked bool, changed func(checked bool)) *Form { f.Lock() defer f.Unlock() f.items = append(f.items, NewCheckBox(). SetLabel(label). SetMessage(message). SetChecked(checked). SetChangedFunc(changed)) return f } // AddButton adds a new button to the form. The "selected" function is called // when the user selects this button. It may be nil. func (f *Form) AddButton(label string, selected func()) *Form { f.Lock() defer f.Unlock() f.buttons = append(f.buttons, NewButton(label).SetSelectedFunc(selected)) return f } // GetButton returns the button at the specified 0-based index. Note that // buttons have been specially prepared for this form and modifying some of // their attributes may have unintended side effects. func (f *Form) GetButton(index int) *Button { f.RLock() defer f.RUnlock() return f.buttons[index] } // RemoveButton removes the button at the specified position, starting with 0 // for the button that was added first. func (f *Form) RemoveButton(index int) *Form { f.Lock() defer f.Unlock() f.buttons = append(f.buttons[:index], f.buttons[index+1:]...) return f } // GetButtonCount returns the number of buttons in this form. func (f *Form) GetButtonCount() int { f.RLock() defer f.RUnlock() return len(f.buttons) } // GetButtonIndex returns the index of the button with the given label, starting // with 0 for the button that was added first. If no such label was found, -1 // is returned. func (f *Form) GetButtonIndex(label string) int { f.RLock() defer f.RUnlock() for index, button := range f.buttons { if button.GetLabel() == label { return index } } return -1 } // Clear removes all input elements from the form, including the buttons if // specified. func (f *Form) Clear(includeButtons bool) *Form { f.Lock() defer f.Unlock() f.items = nil if includeButtons { f.buttons = nil } f.focusedElement = 0 return f } // ClearButtons removes all buttons from the form. func (f *Form) ClearButtons() *Form { f.Lock() defer f.Unlock() f.buttons = nil return f } // AddFormItem adds a new item to the form. This can be used to add your own // objects to the form. Note, however, that the Form class will override some // of its attributes to make it work in the form context. Specifically, these // are: // // - The label width // - The label color // - The background color // - The field text color // - The field background color func (f *Form) AddFormItem(item FormItem) *Form { f.Lock() defer f.Unlock() f.items = append(f.items, item) return f } // GetFormItemCount returns the number of items in the form (not including the // buttons). func (f *Form) GetFormItemCount() int { f.RLock() defer f.RUnlock() return len(f.items) } // IndexOfFormItem returns the index of the given FormItem. func (f *Form) IndexOfFormItem(item FormItem) int { f.l.RLock() defer f.l.RUnlock() for index, formItem := range f.items { if item == formItem { return index } } return -1 } // GetFormItem returns the form item at the given position, starting with index // 0. Elements are referenced in the order they were added. Buttons are not included. // If index is out of bounds it returns nil. func (f *Form) GetFormItem(index int) FormItem { f.RLock() defer f.RUnlock() if index > len(f.items)-1 || index < 0 { return nil } return f.items[index] } // RemoveFormItem removes the form element at the given position, starting with // index 0. Elements are referenced in the order they were added. Buttons are // not included. func (f *Form) RemoveFormItem(index int) *Form { f.Lock() defer f.Unlock() f.items = append(f.items[:index], f.items[index+1:]...) return f } // GetFormItemByLabel returns the first form element with the given label. If // no such element is found, nil is returned. Buttons are not searched and will // therefore not be returned. func (f *Form) GetFormItemByLabel(label string) FormItem { f.RLock() defer f.RUnlock() for _, item := range f.items { if item.GetLabel() == label { return item } } return nil } // GetFormItemIndex returns the index of the first form element with the given // label. If no such element is found, -1 is returned. Buttons are not searched // and will therefore not be returned. func (f *Form) GetFormItemIndex(label string) int { f.RLock() defer f.RUnlock() for index, item := range f.items { if item.GetLabel() == label { return index } } return -1 } // GetFocusedItemIndex returns the indices of the form element or button which // currently has focus. If they don't, -1 is returned resepectively. func (f *Form) GetFocusedItemIndex() (formItem, button int) { f.RLock() defer f.RUnlock() index := f.focusIndex() if index < 0 { return -1, -1 } if index < len(f.items) { return index, -1 } return -1, index - len(f.items) } // SetWrapAround sets the flag that determines whether navigating the form will // wrap around. That is, navigating downwards on the last item will move the // selection to the first item (similarly in the other direction). If set to // false, the selection won't change when navigating downwards on the last item // or navigating upwards on the first item. func (f *Form) SetWrapAround(wrapAround bool) *Form { f.Lock() defer f.Unlock() f.wrapAround = wrapAround return f } // SetCancelFunc sets a handler which is called when the user hits the Escape // key. func (f *Form) SetCancelFunc(callback func()) *Form { f.Lock() defer f.Unlock() f.cancel = callback return f } // Draw draws this primitive onto the screen. func (f *Form) Draw(screen tcell.Screen) { f.Box.Draw(screen) f.Lock() defer f.Unlock() // Determine the actual item that has focus. if index := f.focusIndex(); index >= 0 { f.focusedElement = index } // Determine the dimensions. x, y, width, height := f.GetInnerRect() topLimit := y bottomLimit := y + height rightLimit := x + width startX := x // Find the longest label. var maxLabelWidth int for _, item := range f.items { labelWidth := TaggedStringWidth(item.GetLabel()) if labelWidth > maxLabelWidth { maxLabelWidth = labelWidth } } maxLabelWidth++ // Add one space. // Calculate positions of form items. positions := make([]struct{ x, y, width, height int }, len(f.items)+len(f.buttons)) var focusedPosition struct{ x, y, width, height int } for index, item := range f.items { // Calculate the space needed. labelWidth := TaggedStringWidth(item.GetLabel()) var itemWidth int if f.horizontal { fieldWidth := item.GetFieldWidth() if fieldWidth == 0 { fieldWidth = DefaultFormFieldWidth } labelWidth++ itemWidth = labelWidth + fieldWidth } else { // We want all fields to align vertically. labelWidth = maxLabelWidth itemWidth = width } // Advance to next line if there is no space. if f.horizontal && x+labelWidth+1 >= rightLimit { x = startX y += 2 } // Adjust the item's attributes. if x+itemWidth >= rightLimit { itemWidth = rightLimit - x } item.SetFormAttributes( labelWidth, f.backgroundColor, f.labelColor, f.labelColorFocused, f.fieldTextColor, f.fieldTextColorFocused, f.fieldBackgroundColor, f.fieldBackgroundColorFocused, ) // Save position. positions[index].x = x positions[index].y = y positions[index].width = itemWidth positions[index].height = 1 if item.GetFocusable().HasFocus() { focusedPosition = positions[index] } // Advance to next item. if f.horizontal { x += itemWidth + f.itemPadding } else { y += item.GetFieldHeight() + f.itemPadding } } // How wide are the buttons? buttonWidths := make([]int, len(f.buttons)) buttonsWidth := 0 for index, button := range f.buttons { w := TaggedStringWidth(button.GetLabel()) + 4 buttonWidths[index] = w buttonsWidth += w + 1 } buttonsWidth-- // Where do we place them? if !f.horizontal && x+buttonsWidth < rightLimit { if f.buttonsAlign == AlignRight { x = rightLimit - buttonsWidth } else if f.buttonsAlign == AlignCenter { x = (x + rightLimit - buttonsWidth) / 2 } // In vertical layouts, buttons always appear after an empty line. if f.itemPadding == 0 { y++ } } // Calculate positions of buttons. for index, button := range f.buttons { space := rightLimit - x buttonWidth := buttonWidths[index] if f.horizontal { if space < buttonWidth-4 { x = startX y += 2 space = width } } else { if space < 1 { break // No space for this button anymore. } } if buttonWidth > space { buttonWidth = space } button.SetLabelColor(f.buttonTextColor). SetLabelColorFocused(f.buttonTextColorFocused). SetBackgroundColorFocused(f.buttonBackgroundColorFocused). SetBackgroundColor(f.buttonBackgroundColor) buttonIndex := index + len(f.items) positions[buttonIndex].x = x positions[buttonIndex].y = y positions[buttonIndex].width = buttonWidth positions[buttonIndex].height = 1 if button.HasFocus() { focusedPosition = positions[buttonIndex] } x += buttonWidth + 1 } // Determine vertical offset based on the position of the focused item. var offset int if focusedPosition.y+focusedPosition.height > bottomLimit { offset = focusedPosition.y + focusedPosition.height - bottomLimit if focusedPosition.y-offset < topLimit { offset = focusedPosition.y - topLimit } } // Draw items. for index, item := range f.items { // Set position. y := positions[index].y - offset height := positions[index].height item.SetRect(positions[index].x, y, positions[index].width, height) // Is this item visible? if y+height <= topLimit || y >= bottomLimit { continue } // Draw items with focus last (in case of overlaps). if item.GetFocusable().HasFocus() { defer item.Draw(screen) } else { item.Draw(screen) } } // Draw buttons. for index, button := range f.buttons { // Set position. buttonIndex := index + len(f.items) y := positions[buttonIndex].y - offset height := positions[buttonIndex].height button.SetRect(positions[buttonIndex].x, y, positions[buttonIndex].width, height) // Is this button visible? if y+height <= topLimit || y >= bottomLimit { continue } // Draw button. button.Draw(screen) } } // Focus is called by the application when the primitive receives focus. func (f *Form) Focus(delegate func(p Primitive)) { f.Lock() if len(f.items)+len(f.buttons) == 0 { f.hasFocus = true f.Unlock() return } f.hasFocus = false // Hand on the focus to one of our child elements. if f.focusedElement < 0 || f.focusedElement >= len(f.items)+len(f.buttons) { f.focusedElement = 0 } handler := func(key tcell.Key) { f.Lock() switch key { case tcell.KeyTab, tcell.KeyEnter: f.focusedElement++ if !f.wrapAround && f.focusedElement >= len(f.items)+len(f.buttons) { f.focusedElement = (len(f.items) + len(f.buttons)) - 1 } f.Unlock() f.Focus(delegate) f.Lock() case tcell.KeyBacktab: f.focusedElement-- if f.focusedElement < 0 { if f.wrapAround { f.focusedElement = len(f.items) + len(f.buttons) - 1 } else { f.focusedElement = 0 } } f.Unlock() f.Focus(delegate) f.Lock() case tcell.KeyEscape: if f.cancel != nil { f.Unlock() f.cancel() f.Lock() } else { f.focusedElement = 0 f.Unlock() f.Focus(delegate) f.Lock() } } f.Unlock() } if f.focusedElement < len(f.items) { // We're selecting an item. item := f.items[f.focusedElement] item.SetFinishedFunc(handler) f.Unlock() delegate(item) } else { // We're selecting a button. button := f.buttons[f.focusedElement-len(f.items)] button.SetBlurFunc(handler) f.Unlock() delegate(button) } } // HasFocus returns whether or not this primitive has focus. func (f *Form) HasFocus() bool { f.Lock() defer f.Unlock() if f.hasFocus { return true } return f.focusIndex() >= 0 } // focusIndex returns the index of the currently focused item, counting form // items first, then buttons. A negative value indicates that no containeed item // has focus. func (f *Form) focusIndex() int { for index, item := range f.items { if item.GetFocusable().HasFocus() { return index } } for index, button := range f.buttons { if button.focus.HasFocus() { return len(f.items) + index } } return -1 } // MouseHandler returns the mouse handler for this primitive. func (f *Form) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { return f.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { if !f.InRect(event.Position()) { return false, nil } // Determine items to pass mouse events to. for _, item := range f.items { consumed, capture = item.MouseHandler()(action, event, setFocus) if consumed { return } } for _, button := range f.buttons { consumed, capture = button.MouseHandler()(action, event, setFocus) if consumed { return } } // A mouse click anywhere else will return the focus to the last selected // element. if action == MouseLeftClick { if f.focusedElement < len(f.items) { setFocus(f.items[f.focusedElement]) } else if f.focusedElement < len(f.items)+len(f.buttons) { setFocus(f.buttons[f.focusedElement-len(f.items)]) } consumed = true } return }) }