Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add vertical/horizontal radio button group as a form item #799

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions demos/form/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
12 changes: 12 additions & 0 deletions form.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
286 changes: 286 additions & 0 deletions radio.go
Original file line number Diff line number Diff line change
@@ -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
})
}