pkg/selector/outputs/tableView.go (307 lines of code) (raw):
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package outputs
import (
"fmt"
"reflect"
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/evertras/bubble-table/table"
"github.com/aws/amazon-ec2-instance-selector/v3/pkg/instancetypes"
"github.com/aws/amazon-ec2-instance-selector/v3/pkg/sorter"
)
const (
// table formatting.
headerAndFooterPadding = 8
headerPadding = 2
// controls.
tableControls = "Controls: ↑/↓ - up/down • ←/→ - left/right • shift + ←/→ - pg up/down • e - expand • f - filter • t - trim toggle • space - select • s - sort • q - quit"
ellipses = "..."
jsonPathError = "INVALID JSON PATH"
)
type tableModel struct {
// the model for the table output
table table.Model
// width and rows per page are inaccessible through
// bubble tea implementation, so expose them here
tableWidth int
tableRowsPerPage int
// the model for the filtering text input
filterTextInput textinput.Model
// shows whether the rows are currently trimmed or not
isTrimmed bool
// the rows that existed on the table's creation
originalRows []table.Row
canSelectRows bool
}
var customBorder = table.Border{
Top: "─",
Left: "│",
Right: "│",
Bottom: "─",
TopRight: "╮",
TopLeft: "╭",
BottomRight: "╯",
BottomLeft: "╰",
TopJunction: "┬",
LeftJunction: "├",
RightJunction: "┤",
BottomJunction: "┴",
InnerJunction: "┼",
InnerDivider: "│",
}
// initTableModel initializes and returns a new tableModel based on the given
// instance type details.
func initTableModel(instanceTypes []*instancetypes.Details) *tableModel {
table := createTable(instanceTypes)
return &tableModel{
table: table,
tableWidth: initialDimensionVal,
filterTextInput: createFilterTextInput(),
isTrimmed: false,
originalRows: table.GetVisibleRows(),
canSelectRows: true,
}
}
// createFilterTextInput creates and styles a text input for filtering.
func createFilterTextInput() textinput.Model {
filterTextInput := textinput.New()
filterTextInput.Prompt = "Filter: "
filterTextInput.PromptStyle = lipgloss.NewStyle().Bold(true)
return filterTextInput
}
// createRows creates a row for each instance type in the passed in list.
func createRows(columnsData []*wideColumnsData, instanceTypes []*instancetypes.Details) *[]table.Row {
rows := []table.Row{}
// create a row for each instance type
for i, data := range columnsData {
rowData := table.RowData{}
// create a new row by iterating through the column data
// struct and using struct tags as column keys
structType := reflect.TypeOf(*data)
structValue := reflect.ValueOf(*data)
for i := 0; i < structType.NumField(); i++ {
currField := structType.Field(i)
columnName := currField.Tag.Get(columnTag)
colValue := structValue.Field(i)
rowData[columnName] = getUnderlyingValue(colValue)
}
// add instance type as metaData
rowData[instanceTypeKey] = instanceTypes[i]
// add selected flag as metadata
rowData[selectedKey] = false
newRow := table.NewRow(rowData)
rows = append(rows, newRow)
}
return &rows
}
// maxColWidth finds the maximum width element in the given column.
func maxColWidth(columnsData []*wideColumnsData, columnHeader string) int {
// default max width is the width of the header itself with padding
maxWidth := len(columnHeader) + headerPadding
for _, data := range columnsData {
// get data at given column
structType := reflect.TypeOf(*data)
structValue := reflect.ValueOf(*data)
var underlyingValue interface{}
for i := 0; i < structType.NumField(); i++ {
currField := structType.Field(i)
columnName := currField.Tag.Get(columnTag)
if columnName == columnHeader {
colValue := structValue.Field(i)
underlyingValue = getUnderlyingValue(colValue)
break
}
}
// see if the width of the current column element exceeds
// the previous max width
currWidth := len(fmt.Sprintf("%v", underlyingValue))
if currWidth > maxWidth {
maxWidth = currWidth
}
}
return maxWidth
}
// createColumns creates columns based on the tags in the wideColumnsData
// struct.
func createColumns(columnsData []*wideColumnsData) *[]table.Column {
columns := []table.Column{}
// iterate through wideColumnsData struct and create a new column for each field tag
columnDataStruct := wideColumnsData{}
structType := reflect.TypeOf(columnDataStruct)
for i := 0; i < structType.NumField(); i++ {
columnHeader := structType.Field(i).Tag.Get(columnTag)
newCol := table.NewColumn(columnHeader, columnHeader, maxColWidth(columnsData, columnHeader)).
WithFiltered(true)
columns = append(columns, newCol)
}
return &columns
}
// createTableKeyMap creates a KeyMap with the controls for the table.
func createTableKeyMap() *table.KeyMap {
keys := table.KeyMap{
RowDown: key.NewBinding(
key.WithKeys("down"),
),
RowUp: key.NewBinding(
key.WithKeys("up"),
),
ScrollLeft: key.NewBinding(
key.WithKeys("left"),
),
ScrollRight: key.NewBinding(
key.WithKeys("right"),
),
PageDown: key.NewBinding(
key.WithKeys("shift+right"),
),
PageUp: key.NewBinding(
key.WithKeys("shift+left"),
),
}
return &keys
}
// createTable creates an intractable table which contains information about all of
// the given instance types.
func createTable(instanceTypes []*instancetypes.Details) table.Model {
// calculate and fetch all column data from instance types
columnsData := getWideColumnsData(instanceTypes)
newTable := table.New(*createColumns(columnsData)).
WithRows(*createRows(columnsData, instanceTypes)).
WithKeyMap(*createTableKeyMap()).
WithPageSize(initialDimensionVal).
Focused(true).
Border(customBorder).
WithMaxTotalWidth(initialDimensionVal).
WithHorizontalFreezeColumnCount(1).
WithBaseStyle(
lipgloss.NewStyle().
Align((lipgloss.Left)),
).
HeaderStyle(lipgloss.NewStyle().Align(lipgloss.Center).Bold(true)).
Filtered(true).
SelectableRows(true)
return newTable
}
// resizeView will change the dimensions of the table in order to accommodate
// the new window dimensions represented by the given tea.WindowSizeMsg.
func (m tableModel) resizeView(msg tea.WindowSizeMsg) tableModel {
// handle width changes
m.table = m.table.WithMaxTotalWidth(msg.Width)
m.tableWidth = msg.Width
// handle height changes
if headerAndFooterPadding >= msg.Height {
// height too short to fit footer and header
// so only display 1 row
m.table = m.table.WithPageSize(1)
m.table = m.table.WithFooterVisibility(false)
m.table = m.table.WithHeaderVisibility(false)
m.tableRowsPerPage = 1
} else {
newRowsPerPage := msg.Height - headerAndFooterPadding
m.table = m.table.WithPageSize(newRowsPerPage)
m.table = m.table.WithFooterVisibility(true)
m.table = m.table.WithHeaderVisibility(true)
m.tableRowsPerPage = newRowsPerPage
}
return m
}
// updateFooter updates the page and controls string in the table footer.
func (m tableModel) updateFooter() tableModel {
controlsStr := tableControls
// prevent controls text from wrapping to avoid table misprints
pageStr := fmt.Sprintf("Page: %d/%d | ", m.table.CurrentPage(), m.table.MaxPages())
if m.tableWidth < len(pageStr)+len(controlsStr) {
controlsWidth := m.tableWidth - len(ellipses) - len(pageStr) - 2
if controlsWidth < 0 {
controlsWidth = 0
} else if controlsWidth > len(tableControls) {
controlsWidth = len(tableControls)
}
controlsStr = tableControls[0:controlsWidth] + ellipses
}
renderedControls := controlsStyle.Render(controlsStr)
footerStr := fmt.Sprintf("%s%s", pageStr, renderedControls)
m.table = m.table.WithStaticFooter(footerStr)
return m
}
// update updates the state of the tableModel.
func (m tableModel) update(msg tea.Msg) (tableModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
// update filtering input field
if m.filterTextInput.Focused() {
var cmd tea.Cmd
if msg.String() == "enter" || msg.String() == "esc" {
// exit filter input and update controls string
m.filterTextInput.Blur()
m = m.updateFooter()
} else {
m.filterTextInput, cmd = m.filterTextInput.Update(msg)
}
m.table = m.table.WithFilterInput(m.filterTextInput)
return m, cmd
}
// listen for specific inputs
switch msg.String() {
case "f":
// focus filter input field
m.filterTextInput.Focus()
case "t":
// handle trimming to selected rows
if m.isTrimmed {
// undo trim
m = m.untrim()
m.isTrimmed = false
} else {
// trim
m = m.trim()
m.isTrimmed = true
}
case " ":
// custom toggling of selected rows because bubble tea implementation
// breaks trimming
if m.canSelectRows {
originalRows := m.getUnfilteredRows()
selectedRow := m.table.HighlightedRow()
isSelected, ok := selectedRow.Data[selectedKey].(bool)
if !ok {
break
}
// flip selected flag
selectedRow.Data[selectedKey] = !isSelected
selectedRow = selectedRow.Selected(!isSelected)
// update selected row with new selected state. Must iterate through
// original rows since the cursor index in the bubble tea table
// takes the filter into account and therefore returns an incorrect index
for i, row := range originalRows {
if row.Data[instanceTypeKey] == selectedRow.Data[instanceTypeKey] {
originalRows[i] = selectedRow
break
}
}
m.table = m.table.WithRows(originalRows)
}
}
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
// update footer
m = m.updateFooter()
return m, cmd
}
// view returns a string representing the table view.
func (m tableModel) view() string {
outputStr := strings.Builder{}
outputStr.WriteString(m.table.View())
outputStr.WriteString("\n")
if m.table.GetIsFilterActive() || m.filterTextInput.Focused() {
outputStr.WriteString(m.filterTextInput.View())
outputStr.WriteString("\n")
}
return outputStr.String()
}
// sortTable sorts the table based on the sorting direction and sorting filter.
func (m tableModel) sortTable(sortFilter string, sortDirection string) (tableModel, error) {
instanceTypes, rowMap := m.getInstanceTypeFromRows()
_ = rowMap
// sort instance types
instanceTypes, err := sorter.Sort(instanceTypes, sortFilter, sortDirection)
if err != nil {
return m, err
}
// get sorted rows from sorted instance types
rows := []table.Row{}
for _, instance := range instanceTypes {
currRow := rowMap[string(instance.InstanceType)]
rows = append(rows, currRow)
}
m.table = m.table.WithRows(rows)
// apply truncation if needed
if m.isTrimmed {
m = m.trim()
}
return m, nil
}
// getInstanceTypeFromRows goes through the rows of the table model and returns both a list of instance
// types and a mapping of instances to rows.
func (m tableModel) getInstanceTypeFromRows() ([]*instancetypes.Details, map[string]table.Row) {
instanceTypes := []*instancetypes.Details{}
rowMap := make(map[string]table.Row)
// get current rows
var rows []table.Row
if m.isTrimmed {
// if current table is trimmed, get the stored untrimmed rows
rows = m.originalRows
} else {
// since table isn't trimmed, we should get the unfiltered rows
// so that our rows have the most updated selected flags
rows = m.getUnfilteredRows()
}
for _, row := range rows {
currInstance, ok := row.Data[instanceTypeKey].(*instancetypes.Details)
if !ok {
continue
}
instanceTypes = append(instanceTypes, currInstance)
rowMap[string(currInstance.InstanceType)] = row
}
return instanceTypes, rowMap
}
// getUnfilteredRows gets the rows in the given table model without any filtering applied.
func (m tableModel) getUnfilteredRows() []table.Row {
m.table = m.table.Filtered(false)
rows := m.table.GetVisibleRows()
return rows
}
// trim will trim the table to only the selected rows.
func (m tableModel) trim() tableModel {
// store current state of rows before trimming
m.originalRows = m.getUnfilteredRows()
// prevent rows from being selected until trim is
// undone
m.table = m.table.SelectableRows(false)
m.canSelectRows = false
m.table = m.table.WithRows(m.table.SelectedRows())
m.isTrimmed = true
return m
}
// untrim will return the table to the original rows.
func (m tableModel) untrim() tableModel {
m.table = m.table.WithRows(m.originalRows)
// allow rows to be selected again
m.table = m.table.SelectableRows(true)
m.canSelectRows = true
return m
}