feat: change UI component for disks selector

Implemented a new component based on `tview.Table` that shows all
available block devices as a table.

Had to stop using standard form control, because it doesn't really
handle multiline elements.

Signed-off-by: Artem Chernyshev <artem.0xD2@gmail.com>
This commit is contained in:
Artem Chernyshev 2020-11-24 22:49:02 +03:00 committed by talos-bot
parent dd810d0514
commit 07b7a8c103
5 changed files with 381 additions and 76 deletions

View File

@ -0,0 +1,76 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package components
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
// NewForm creates a new form.
func NewForm() *Form {
return &Form{
Flex: tview.NewFlex().SetDirection(tview.FlexRow),
formItems: []tview.FormItem{},
}
}
// Form is a more flexible form component for tview lib.
type Form struct {
*tview.Flex
formItems []tview.FormItem
maxLabelLen int
}
// AddFormItem adds a new item to the form.
func (f *Form) AddFormItem(item tview.Primitive) {
if formItem, ok := item.(tview.FormItem); ok {
f.formItems = append(f.formItems, formItem)
labelLen := len(formItem.GetLabel()) + 1
if labelLen > f.maxLabelLen {
for _, item := range f.formItems[:len(f.formItems)-1] {
item.SetFormAttributes(
labelLen,
tview.Styles.PrimaryTextColor,
f.GetBackgroundColor(),
tview.Styles.PrimaryTextColor,
tview.Styles.ContrastBackgroundColor,
)
}
f.maxLabelLen = labelLen
}
formItem.SetFormAttributes(
f.maxLabelLen,
tview.Styles.PrimaryTextColor,
f.GetBackgroundColor(),
tview.Styles.PrimaryTextColor,
tview.Styles.ContrastBackgroundColor,
)
} else if box, ok := item.(Box); ok {
box.SetBackgroundColor(f.GetBackgroundColor())
}
height := 1
multiline, ok := item.(Multiline)
if ok {
height = multiline.GetHeight()
}
f.AddItem(item, height+1, 1, false)
}
// Multiline interface represents elements that can occupy more than one line.
type Multiline interface {
GetHeight() int
}
// Box interface that has just SetBackgroundColor.
type Box interface {
SetBackgroundColor(tcell.Color) *tview.Box
}

View File

@ -5,7 +5,6 @@
package components package components
import ( import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
) )
@ -15,8 +14,6 @@ func NewFormLabel(label string) *FormLabel {
tview.NewTextView().SetText(label), tview.NewTextView().SetText(label),
} }
res.SetWordWrap(true)
return res return res
} }
@ -24,25 +21,3 @@ func NewFormLabel(label string) *FormLabel {
type FormLabel struct { type FormLabel struct {
*tview.TextView *tview.TextView
} }
// GetLabel implements FormItem interface.
func (fl *FormLabel) GetLabel() string {
return ""
}
// SetFormAttributes implements FormItem interface.
func (fl *FormLabel) SetFormAttributes(labelWidth int, labelColor, bgColor, fieldTextColor, fieldBgColor tcell.Color) tview.FormItem {
fl.SetBackgroundColor(bgColor)
return fl
}
// GetFieldWidth implements FormItem interface.
func (fl *FormLabel) GetFieldWidth() int {
return 0
}
// SetFinishedFunc implements FormItem interface.
func (fl *FormLabel) SetFinishedFunc(handler func(key tcell.Key)) tview.FormItem {
return fl
}

View File

@ -0,0 +1,210 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package components
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
var (
backgroundColor = tcell.Color235
textNormalColor = tcell.ColorIvory
selectedTextColor = tview.Styles.PrimaryTextColor
selectedBackgroundColor = tview.Styles.ContrastBackgroundColor
)
// NewTable creates new table.
func NewTable() *Table {
t := &Table{
Table: tview.NewTable(),
selectedRow: -1,
hoveredRow: -1,
rows: [][]interface{}{},
}
hasFocus := false
t.SetDrawFunc(func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) {
changed := hasFocus != t.HasFocus()
if !changed {
return x, y, width, height
}
hasFocus = t.HasFocus()
if hasFocus {
if t.selectedRow != -1 {
t.HoverRow(t.selectedRow)
} else {
t.HoverRow(1)
}
} else {
t.HoverRow(-1)
}
return x, y, width, height
})
t.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey {
// nolint:exhaustive
switch e.Key() {
case tcell.KeyUp:
if t.hoveredRow > 0 {
t.HoverRow(t.hoveredRow - 1)
}
case tcell.KeyDown:
if t.hoveredRow < t.GetRowCount() {
t.HoverRow(t.hoveredRow + 1)
}
case tcell.KeyEnter:
if t.hoveredRow != -1 {
t.SelectRow(t.hoveredRow)
}
}
return e
})
return t
}
// Table list of choices represented in table format.
type Table struct {
*tview.Table
selectedRow int
hoveredRow int
onRowSelected func(row int)
rows [][]interface{}
}
// SetHeader sets table header.
func (t *Table) SetHeader(keys ...interface{}) {
t.AddRow(keys...)
}
// AddRow adds a new row to the table.
func (t *Table) AddRow(columns ...interface{}) {
row := t.GetRowCount()
col := backgroundColor
textColor := tview.Styles.PrimaryTextColor
if row == 0 {
col = tcell.ColorSilver
textColor = tview.Styles.InverseTextColor
} else {
t.rows = append(t.rows, columns)
}
cell := tview.NewTableCell(" ").
SetAlign(tview.AlignCenter).
SetBackgroundColor(col).
SetTextColor(textColor)
if row > 0 {
cell.SetClickedFunc(func() bool {
t.HoverRow(row)
t.SelectRow(row)
return true
})
}
t.SetCell(row, 0, cell)
for i, text := range columns {
cell = tview.NewTableCell(text.(string))
cell.SetExpansion(1)
cell.SetTextColor(textColor)
if i == len(columns)-1 {
cell.SetAlign(tview.AlignRight)
}
cell.SetBackgroundColor(col)
cell.SetSelectable(true)
t.SetCell(row, i+1, cell)
if row > 0 {
cell.SetClickedFunc(func() bool {
return t.HoverRow(row)
})
}
}
}
// SelectRow selects the row in the table.
func (t *Table) SelectRow(row int) bool {
// don't select the header
if row < 2 {
row = 1
}
if row < t.GetRowCount() {
if t.selectedRow != -1 {
t.GetCell(t.selectedRow, 0).SetText(" ")
}
t.GetCell(row, 0).SetText("►")
t.selectedRow = row
if t.onRowSelected != nil {
t.onRowSelected(row)
}
return true
}
return false
}
// HoverRow highlights the row in the table.
func (t *Table) HoverRow(row int) bool {
updateRowStyle := func(r int, foregroundColor, backgroundColor tcell.Color) {
for i := 0; i < t.GetColumnCount(); i++ {
t.GetCell(r, i).SetBackgroundColor(backgroundColor).SetTextColor(foregroundColor)
}
}
// don't select the header
if row == 0 {
row = 1
}
if row < t.GetRowCount() {
if t.hoveredRow != -1 {
updateRowStyle(t.hoveredRow, textNormalColor, backgroundColor)
}
if row != -1 {
updateRowStyle(row, selectedTextColor, selectedBackgroundColor)
}
t.hoveredRow = row
return true
}
return false
}
// GetHeight implements Multiline interface.
func (t *Table) GetHeight() int {
return t.GetRowCount()
}
// SetRowSelectedFunc called when selected row is updated.
func (t *Table) SetRowSelectedFunc(callback func(row int)) {
t.onRowSelected = callback
}
// GetValue returns value in row/column.
func (t *Table) GetValue(row, column int) interface{} {
if row < len(t.rows) && column < len(t.rows[row]) {
return t.rows[row][column]
}
return ""
}

View File

@ -45,6 +45,14 @@ type Item struct {
options []interface{} options []interface{}
} }
// TableHeaders represents table headers list for item options which are using table representation.
type TableHeaders []interface{}
// NewTableHeaders creates TableHeaders object.
func NewTableHeaders(headers ...interface{}) TableHeaders {
return TableHeaders(headers)
}
// NewItem creates new form item. // NewItem creates new form item.
func NewItem(name, description string, dest interface{}, options ...interface{}) *Item { func NewItem(name, description string, dest interface{}, options ...interface{}) *Item {
return &Item{ return &Item{
@ -62,8 +70,8 @@ func (item *Item) assign(value string) error {
// createFormItems dynamically creates tview.FormItem list based on the wrapped type. // createFormItems dynamically creates tview.FormItem list based on the wrapped type.
// nolint:gocyclo // nolint:gocyclo
func (item *Item) createFormItems() ([]tview.FormItem, error) { func (item *Item) createFormItems() ([]tview.Primitive, error) {
res := []tview.FormItem{} res := []tview.Primitive{}
v := reflect.ValueOf(item.dest) v := reflect.ValueOf(item.dest)
if v.Kind() == reflect.Ptr { if v.Kind() == reflect.Ptr {
@ -77,7 +85,7 @@ func (item *Item) createFormItems() ([]tview.FormItem, error) {
} }
} }
var formItem tview.FormItem var formItem tview.Primitive
// nolint:exhaustive // nolint:exhaustive
switch v.Kind() { switch v.Kind() {
@ -92,37 +100,69 @@ func (item *Item) createFormItems() ([]tview.FormItem, error) {
formItem = checkbox formItem = checkbox
default: default:
if len(item.options) > 0 { if len(item.options) > 0 {
dropdown := tview.NewDropDown() tableHeaders, ok := item.options[0].(TableHeaders)
if ok {
table := components.NewTable()
table.SetHeader(tableHeaders...)
if len(item.options)%2 != 0 { data := item.options[1:]
return nil, fmt.Errorf("wrong amount of arguments for options: should be even amount of key, value pairs") numColumns := len(tableHeaders)
}
for i := 0; i < len(item.options); i += 2 { if len(data)%numColumns != 0 {
if optionName, ok := item.options[i].(string); ok { return nil, fmt.Errorf("incorrect amount of data provided for the table")
selected := -1
func(index int) {
dropdown.AddOption(optionName, func() {
v.Set(reflect.ValueOf(item.options[index]))
})
if v.Interface() == item.options[index] {
selected = i / 2
}
}(i + 1)
if selected != -1 {
dropdown.SetCurrentOption(selected)
}
} else {
return nil, fmt.Errorf("expected string option name, got %s", item.options[i])
} }
selected := -1
for i := 0; i < len(data); i += numColumns {
table.AddRow(data[i : i+numColumns]...)
if v.Interface() == data[i] {
selected = i / numColumns
}
}
if selected != -1 {
table.SelectRow(selected)
}
formItem = table
table.SetRowSelectedFunc(func(row int) {
v.Set(reflect.ValueOf(table.GetValue(row, 0))) // always pick the first column
})
} else {
dropdown := tview.NewDropDown()
if len(item.options)%2 != 0 {
return nil, fmt.Errorf("wrong amount of arguments for options: should be even amount of key, value pairs")
}
for i := 0; i < len(item.options); i += 2 {
if optionName, ok := item.options[i].(string); ok {
selected := -1
func(index int) {
dropdown.AddOption(optionName, func() {
v.Set(reflect.ValueOf(item.options[index]))
})
if v.Interface() == item.options[index] {
selected = i / 2
}
}(i + 1)
if selected != -1 {
dropdown.SetCurrentOption(selected)
}
} else {
return nil, fmt.Errorf("expected string option name, got %s", item.options[i])
}
}
dropdown.SetLabel(item.name)
formItem = dropdown
} }
dropdown.SetLabel(item.name)
formItem = dropdown
} else { } else {
input := tview.NewInputField() input := tview.NewInputField()
formItem = input formItem = input
@ -375,7 +415,8 @@ func (installer *Installer) configure() error {
groups = append(groups, eg) groups = append(groups, eg)
err = func(index int) error { err = func(index int) error {
form := tview.NewForm() form := components.NewForm()
form.SetBackgroundColor(color)
for _, item := range p.items { for _, item := range p.items {
formItems, e := item.createFormItems() formItems, e := item.createFormItems()

View File

@ -46,7 +46,9 @@ func NewState(ctx context.Context, conn *Connection) (*State, error) {
opts.ClusterConfig.ControlPlane.Endpoint = fmt.Sprintf("https://%s:%d", conn.nodeEndpoint, constants.DefaultControlPlanePort) opts.ClusterConfig.ControlPlane.Endpoint = fmt.Sprintf("https://%s:%d", conn.nodeEndpoint, constants.DefaultControlPlanePort)
} }
diskInstallOptions := []interface{}{} diskInstallOptions := []interface{}{
NewTableHeaders("device name", "model name", "size"),
}
disks, err := conn.Disks() disks, err := conn.Disks()
if err != nil { if err != nil {
@ -58,8 +60,7 @@ func NewState(ctx context.Context, conn *Connection) (*State, error) {
opts.MachineConfig.InstallConfig.InstallDisk = disk.DeviceName opts.MachineConfig.InstallConfig.InstallDisk = disk.DeviceName
} }
name := fmt.Sprintf("%s %s %s", disk.DeviceName, disk.Model, humanize.Bytes(disk.Size)) diskInstallOptions = append(diskInstallOptions, disk.DeviceName, disk.Model, humanize.Bytes(disk.Size))
diskInstallOptions = append(diskInstallOptions, name, disk.DeviceName)
} }
var machineTypes []interface{} var machineTypes []interface{}
@ -80,7 +81,26 @@ func NewState(ctx context.Context, conn *Connection) (*State, error) {
conn: conn, conn: conn,
opts: opts, opts: opts,
pages: []*Page{ pages: []*Page{
NewPage("Installer Params",
NewItem(
"image",
v1alpha1.InstallConfigDoc.Describe("image", true),
&opts.MachineConfig.InstallConfig.InstallImage,
),
NewItem(
"install disk",
v1alpha1.InstallConfigDoc.Describe("disk", true),
&opts.MachineConfig.InstallConfig.InstallDisk,
diskInstallOptions...,
),
),
NewPage("Machine Config", NewPage("Machine Config",
NewItem(
"machine type",
v1alpha1.MachineConfigDoc.Describe("type", true),
&opts.MachineConfig.Type,
machineTypes...,
),
NewItem( NewItem(
"cluster name", "cluster name",
v1alpha1.ClusterConfigDoc.Describe("clusterName", true), v1alpha1.ClusterConfigDoc.Describe("clusterName", true),
@ -91,28 +111,11 @@ func NewState(ctx context.Context, conn *Connection) (*State, error) {
v1alpha1.ControlPlaneConfigDoc.Describe("endpoint", true), v1alpha1.ControlPlaneConfigDoc.Describe("endpoint", true),
&opts.ClusterConfig.ControlPlane.Endpoint, &opts.ClusterConfig.ControlPlane.Endpoint,
), ),
NewItem(
"machine type",
v1alpha1.MachineConfigDoc.Describe("type", true),
&opts.MachineConfig.Type,
machineTypes...,
),
NewItem( NewItem(
"kubernetes version", "kubernetes version",
"Kubernetes version to install.", "Kubernetes version to install.",
&opts.MachineConfig.KubernetesVersion, &opts.MachineConfig.KubernetesVersion,
), ),
NewItem(
"install disk",
v1alpha1.InstallConfigDoc.Describe("disk", true),
&opts.MachineConfig.InstallConfig.InstallDisk,
diskInstallOptions...,
),
NewItem(
"image",
v1alpha1.InstallConfigDoc.Describe("image", true),
&opts.MachineConfig.InstallConfig.InstallImage,
),
), ),
NewPage("Network Config", NewPage("Network Config",
NewItem( NewItem(