From 51a3f8bf1647222343703162655d473be0089091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20B=C3=B3di?= Date: Sat, 28 Jan 2023 17:12:33 +0100 Subject: [PATCH] Add vertical/horizontal radio button group as a form item --- demos/form/main.go | 18 ++- form.go | 12 ++ radio.go | 286 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 radio.go diff --git a/demos/form/main.go b/demos/form/main.go index 317eb59a..f8f4d4fb 100644 --- a/demos/form/main.go +++ b/demos/form/main.go @@ -7,12 +7,24 @@ import ( func main() { app := tview.NewApplication() - form := tview.NewForm(). - AddDropDown("Title", []string{"Mr.", "Ms.", "Mrs.", "Dr.", "Prof."}, 0, nil). + + maleTitles := []string{"Mr.", "Dr.", "Prof."} + femaleTitles := []string{"Ms.", "Mrs.", "Dr.", "Prof."} + + form := tview.NewForm() + form.AddDropDown("Title", maleTitles, 0, nil). AddInputField("First name", "", 20, nil, nil). AddInputField("Last name", "", 20, nil, nil). AddTextArea("Address", "", 40, 0, 0, nil). - AddTextView("Notes", "This is just a demo.\nYou can enter whatever you wish.", 40, 2, true, false). + AddRadio("Sex", 0, true, func(newValue int) { + dd := form.GetFormItem(0).(*tview.DropDown) + if newValue == 0 { + dd.SetOptions(maleTitles, nil) + } else { + dd.SetOptions(femaleTitles, nil) + } + }, "male", "female"). + AddTextView("Notes", "This is just a demo.\nYou can enter whatever you wish.\nMind how the radio changes title options", 40, 3, true, false). AddCheckbox("Age 18+", false, nil). AddPasswordField("Password", "", 10, '*', nil). AddButton("Save", nil). diff --git a/form.go b/form.go index f8825aa1..ea504490 100644 --- a/form.go +++ b/form.go @@ -309,6 +309,18 @@ func (f *Form) AddCheckbox(label string, checked bool, changed func(checked bool return f } +// AddRadio adds a radio button group to the form. It has a label, an initial value, +// if it's horizontal or vertical, and an (optional) callback function which is invoked +// when the state of the radio was changed by the user. +func (f *Form) AddRadio(label string, option int, horizontal bool, changed func(option int), options ...string) *Form { + f.items = append(f.items, NewRadio(options...). + SetLabel(label). + SetValue(option). + SetHorizontal(horizontal). + SetOnSetValue(changed)) + return f +} + // AddImage adds an image to the form. It has a label and the image will fit in // the specified width and height (its aspect ratio is preserved). See // [Image.SetColors] for a description of the "colors" parameter. Images are not diff --git a/radio.go b/radio.go new file mode 100644 index 00000000..395d0efa --- /dev/null +++ b/radio.go @@ -0,0 +1,286 @@ +package tview + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/uniseg" +) + +var ( + // RadioCheckedString and RadioUncheckedString are visible characters of checked and unchecked + // radio buttons. They can be set globally for the whole app through these variables. + RadioCheckedString = "\u25c9" + RadioUncheckedString = "\u25ef" +) + +type Radio struct { + *Box + + // The currently selected value. + value int + + // The text to be displayed before the input area. + label string + + // The list of choosable texts. + options []string + + // The screen width of the label area. A value of 0 means use the width of + // the label text. + labelWidth int + + // A callback function when the user changes the radion button value. + onSetValue func(int) + + // The label color. + labelColor tcell.Color + + // The background color of the input area. + fieldBackgroundColor tcell.Color + + // The text color of the input area. + fieldTextColor tcell.Color + + // If set to true, options are positioned from left to right, instead of top to bottom. + horizontal bool + + // A callback function set by the Form class and called when the user leaves this form item. + finished func(tcell.Key) +} + +// NewRadio creates a radio button group with the given options. +func NewRadio(options ...string) *Radio { + if len(options) == 0 { + options = []string{"noOptions"} + } + return &Radio{ + Box: NewBox(), + options: options, + labelColor: Styles.SecondaryTextColor, + fieldBackgroundColor: Styles.ContrastBackgroundColor, + fieldTextColor: Styles.PrimaryTextColor, + } +} + +// SetValue sets the current value of the radio group. +func (r *Radio) SetValue(value int) *Radio { + if r.value == value { + return r + } + if value < 0 { + value = 0 + } else if value >= len(r.options) { + value = len(r.options) - 1 + } + r.changeValue(value) + return r +} + +// Value returns current radio value. +func (r *Radio) Value() int { + return r.value +} + +// changeValue changes the current value, and calls change callback if exists. +func (r *Radio) changeValue(value int) { + r.value = value + if r.onSetValue != nil { + r.onSetValue(value) + } +} + +// SetOnSetValue sets callback handler of a value change. +func (r *Radio) SetOnSetValue(handler func(int)) *Radio { + r.onSetValue = handler + return r +} + +// SetHorizontal sets the direction the options 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 (r *Radio) SetHorizontal(horizontal bool) *Radio { + r.horizontal = horizontal + return r +} + +// InputHandler returns the handler for this primitive. +func (r *Radio) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) { + return r.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) { + key := event.Key() + if r.value > 0 && + ((key == tcell.KeyLeft && r.horizontal) || + (key == tcell.KeyUp && !r.horizontal)) { + r.changeValue(r.value - 1) + return + } + if r.value < len(r.options)-1 && + ((key == tcell.KeyRight && r.horizontal) || + (key == tcell.KeyDown && !r.horizontal)) { + r.changeValue(r.value + 1) + return + } + switch key { + case tcell.KeyEnter, tcell.KeyTab, tcell.KeyBacktab: + if r.finished != nil { + r.finished(key) + } + } + }) +} + +func (r *Radio) GetLabel() string { + return r.label +} + +func (r *Radio) SetLabel(l string) *Radio { + r.label = l + return r +} + +// SetFormAttributes sets attributes shared by all form items. +func (r *Radio) SetFormAttributes(labelWidth int, labelColor, bgColor, fieldTextColor, fieldBgColor tcell.Color) FormItem { + r.labelWidth = labelWidth + r.labelColor = labelColor + r.backgroundColor = bgColor + r.fieldTextColor = fieldTextColor + r.fieldBackgroundColor = fieldBgColor + return r +} + +// SetFinishedFunc sets a callback invoked when the user leaves this form item. +func (r *Radio) SetFinishedFunc(handler func(key tcell.Key)) FormItem { + r.finished = handler + return r +} + +// GetFieldHeight returns this primitive's field height. +func (r *Radio) GetFieldHeight() int { + if r.horizontal { + return 1 + } + return len(r.options) +} + +// GetFieldWidth returns this primitive's field width. +func (r *Radio) GetFieldWidth() int { + w := 0 + for _, option := range r.options { + if r.horizontal { + w += len(option) + 3 // checkbox + space + option + space + continue + } + if len(option) > w { + w = len(option) + } + } + if r.horizontal { + return w - 1 + } + return w + 2 +} + +func (r *Radio) Draw(screen tcell.Screen) { + r.Box.DrawForSubclass(screen, r) + x, y, width, height := r.GetInnerRect() + if width < 1 || height < 1 { + return + } + + // Draw label. + var labelBg tcell.Color + labelStyle := tcell.StyleDefault.Background(r.fieldBackgroundColor).Foreground(r.labelColor) + if r.hasFocus { + labelBg = Styles.MoreContrastBackgroundColor + labelStyle = labelStyle.Background(Styles.InverseTextColor) + } else { + _, labelBg, _ = tcell.StyleDefault.Decompose() + } + if r.labelWidth > 0 { + labelWidth := r.labelWidth + if labelWidth > width { + labelWidth = width + } + printWithStyle(screen, r.label, x, y, 0, labelWidth, AlignLeft, labelStyle, labelBg == tcell.ColorDefault) + x += labelWidth + } else { + _, drawnWidth, _, _ := printWithStyle(screen, r.label, x, y, 0, width, AlignLeft, labelStyle, labelBg == tcell.ColorDefault) + x += drawnWidth + } + + // Draw radio buttons. + fieldStyle := tcell.StyleDefault.Background(r.fieldBackgroundColor).Foreground(r.fieldTextColor) + for i, option := range r.options { + rb := RadioUncheckedString + if i == r.value { + rb = RadioCheckedString + } + line := fmt.Sprintf("%s %s", rb, option) + printWithStyle(screen, line, x, y, 0, width, AlignLeft, fieldStyle, !r.hasFocus || i != r.value) + if r.horizontal { + x += uniseg.GraphemeClusterCount(line) + 1 + } else { + y += 1 + } + } +} + +// MouseHandler returns the mouse handler for this primitive. +func (r *Radio) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + return r.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + if action != MouseLeftDown && action != MouseLeftClick { + return // only interested in these two + } + x, y := event.Position() + if !r.InRect(x, y) { + return // out of widget + } + if action == MouseLeftDown { + setFocus(r) // mouse down then moved: focus radio only + return true, nil + } + rectX, rectY, _, _ := r.GetRect() + x -= rectX + r.labelWidth + y -= rectY + if x < 0 { + return // clicked on the label + } + // countOptLen counts this option's width + countOptLen := func(i int, option string) int { + res := 0 + if i != r.value { + res += uniseg.GraphemeClusterCount(RadioUncheckedString) + } else { + res += uniseg.GraphemeClusterCount(RadioCheckedString) + } + res++ + res += uniseg.GraphemeClusterCount(option) + return res + } + if !r.horizontal { + if y < 0 || len(r.options) <= y { // shouldn't be necessary, make sure not to index out + return + } + if x >= countOptLen(y, r.options[y]) { + return // clicked to the right of this option + } + r.SetValue(y) // clicked on this option + return true, nil + } + if y != 0 { + return // horizontal radio means single line + } + for i, option := range r.options { // sum option widths until match + x -= countOptLen(i, option) + if x < 0 { // match + r.SetValue(i) + return true, nil + } + if x == 0 { // the character between two options + return + } + x-- + } + return // not found + }) +}