diff --git a/internal/pkg/tui/components/form.go b/internal/pkg/tui/components/form.go new file mode 100644 index 000000000..f9b2364be --- /dev/null +++ b/internal/pkg/tui/components/form.go @@ -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 +} diff --git a/internal/pkg/tui/components/formlabel.go b/internal/pkg/tui/components/formlabel.go index b4360afef..c01ecf47a 100644 --- a/internal/pkg/tui/components/formlabel.go +++ b/internal/pkg/tui/components/formlabel.go @@ -5,7 +5,6 @@ package components import ( - "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) @@ -15,8 +14,6 @@ func NewFormLabel(label string) *FormLabel { tview.NewTextView().SetText(label), } - res.SetWordWrap(true) - return res } @@ -24,25 +21,3 @@ func NewFormLabel(label string) *FormLabel { type FormLabel struct { *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 -} diff --git a/internal/pkg/tui/components/table.go b/internal/pkg/tui/components/table.go new file mode 100644 index 000000000..620e01602 --- /dev/null +++ b/internal/pkg/tui/components/table.go @@ -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 "" +} diff --git a/internal/pkg/tui/installer/installer.go b/internal/pkg/tui/installer/installer.go index 35f8cdd4d..19fcaa8c9 100644 --- a/internal/pkg/tui/installer/installer.go +++ b/internal/pkg/tui/installer/installer.go @@ -45,6 +45,14 @@ type Item struct { 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. func NewItem(name, description string, dest interface{}, options ...interface{}) *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. // nolint:gocyclo -func (item *Item) createFormItems() ([]tview.FormItem, error) { - res := []tview.FormItem{} +func (item *Item) createFormItems() ([]tview.Primitive, error) { + res := []tview.Primitive{} v := reflect.ValueOf(item.dest) 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 switch v.Kind() { @@ -92,37 +100,69 @@ func (item *Item) createFormItems() ([]tview.FormItem, error) { formItem = checkbox default: 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 { - return nil, fmt.Errorf("wrong amount of arguments for options: should be even amount of key, value pairs") - } + data := item.options[1:] + numColumns := len(tableHeaders) - 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]) + if len(data)%numColumns != 0 { + return nil, fmt.Errorf("incorrect amount of data provided for the table") } + + 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 { input := tview.NewInputField() formItem = input @@ -375,7 +415,8 @@ func (installer *Installer) configure() error { groups = append(groups, eg) err = func(index int) error { - form := tview.NewForm() + form := components.NewForm() + form.SetBackgroundColor(color) for _, item := range p.items { formItems, e := item.createFormItems() diff --git a/internal/pkg/tui/installer/state.go b/internal/pkg/tui/installer/state.go index f9c83e9ac..9e25d6a6a 100644 --- a/internal/pkg/tui/installer/state.go +++ b/internal/pkg/tui/installer/state.go @@ -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) } - diskInstallOptions := []interface{}{} + diskInstallOptions := []interface{}{ + NewTableHeaders("device name", "model name", "size"), + } disks, err := conn.Disks() if err != nil { @@ -58,8 +60,7 @@ func NewState(ctx context.Context, conn *Connection) (*State, error) { opts.MachineConfig.InstallConfig.InstallDisk = disk.DeviceName } - name := fmt.Sprintf("%s %s %s", disk.DeviceName, disk.Model, humanize.Bytes(disk.Size)) - diskInstallOptions = append(diskInstallOptions, name, disk.DeviceName) + diskInstallOptions = append(diskInstallOptions, disk.DeviceName, disk.Model, humanize.Bytes(disk.Size)) } var machineTypes []interface{} @@ -80,7 +81,26 @@ func NewState(ctx context.Context, conn *Connection) (*State, error) { conn: conn, opts: opts, 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", + NewItem( + "machine type", + v1alpha1.MachineConfigDoc.Describe("type", true), + &opts.MachineConfig.Type, + machineTypes..., + ), NewItem( "cluster name", v1alpha1.ClusterConfigDoc.Describe("clusterName", true), @@ -91,28 +111,11 @@ func NewState(ctx context.Context, conn *Connection) (*State, error) { v1alpha1.ControlPlaneConfigDoc.Describe("endpoint", true), &opts.ClusterConfig.ControlPlane.Endpoint, ), - NewItem( - "machine type", - v1alpha1.MachineConfigDoc.Describe("type", true), - &opts.MachineConfig.Type, - machineTypes..., - ), NewItem( "kubernetes version", "Kubernetes version to install.", &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", NewItem(