Skip to content
Merged
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
118 changes: 118 additions & 0 deletions menu/fields.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package menu

import (
"fmt"
"reflect"
"strconv"
)

Expand Down Expand Up @@ -111,3 +112,120 @@ func (f *menuField) getFieldName() string {
}
return f.name
}

// structData holds onto values returned from
// reflect calls related to the input interface.
type structData struct {
v reflect.Value
t reflect.Type
fields map[int]*reflect.StructField
values map[int]*reflect.Value
}

func (d *structData) init(structValue reflect.Value) {
d.v = structValue
d.t = d.v.Type()
d.fields = getFields(d.t)

values := map[int]*reflect.Value{}
for i := 0; i < d.t.NumField(); i++ {
val := d.v.FieldByName(d.fields[i].Name)
values[i] = &val
}
d.values = values
}

// getFields returns a map of indeces to reflect.StructField pointers,
// given a reflect.Type. It does not account for tags whatsoever.
func getFields(t reflect.Type) map[int]*reflect.StructField {
fields := map[int]*reflect.StructField{}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
fields[i] = &f
}
return fields
}

// getOrderedFields accounts for the presence of `idx` and `bl`
// tags in StructFields to provide a map that defines the order
// by which each struct fields ought be rendered in the terminal.
//
// If the `idx` tag is in use on the struct, it returns a map of `idx`
// tag values corresponding to the indeces of struct fields within the
// struct represented by the given reflect.Type, in the order they
// are declared. The presence or non-presence of the tags is validated
// when reading each field. Fields blacklisted at the type level with
// the `bl` tag are expected not to have an idx tag.
//
// If the first non-blacklisted field is found to have an `idx` tag,
// all others will be expected to have one as well. Likewise, if it
// does not have the tag, all others will be expected not to have the tag.
// When no `idx` tags are used, the map keys and values will match, unless
// offset by 1 after and for each instance where a field is found to be
// blacklisted with the `bl` tag.
//
// Where validation fails, a nil map and error are returned.
func getOrderedFields(t map[int]*reflect.StructField) (map[int]int, error) {
wantIdx := struct {
val bool
isSet bool
}{val: false, isSet: false}

idxTagVals := map[int]int{}
blacklistCount := 0
for i := 0; i < len(t); i++ {
field := t[i]
if field == nil {
return nil, fmt.Errorf("encountered nil struct field")
}

_, isBlacklisted := field.Tag.Lookup("bl")
tagValue, isIndexed := field.Tag.Lookup("idx")

if isBlacklisted {
if isIndexed {
return nil, fmt.Errorf("incompatible struct tags; unexpected `idx` tag found on `bl`-tagged field %s", field.Name)
}
blacklistCount++
continue
}

// NOTE: at this point, can't possibly be blacklisted
if !wantIdx.isSet {
wantIdx.val = (len(idxTagVals) == 0 && isIndexed)
wantIdx.isSet = true
}

if wantIdx.val {
if !isIndexed {
return nil, fmt.Errorf("no `idx` tag found on struct field %s", field.Name)
}
idx, err := strconv.Atoi(tagValue)
if err != nil || idx < 0 {
return nil, fmt.Errorf("value for `idx` tag on field %s must be an integer >= 0", field.Name)
}
if _, ok := idxTagVals[idx]; ok {
return nil, fmt.Errorf("value %d for `idx` tag on field %s already assigned to another field", idx, field.Name)
}
idxTagVals[idx] = i

} else if isIndexed {
return nil, fmt.Errorf("unexpected `idx` tag found on field %s", field.Name)
} else {
idxTagVals[i-blacklistCount] = i
}

}

for i := 0; i < len(idxTagVals); i++ {
if _, ok := idxTagVals[i]; !ok {
if wantIdx.val {
return nil, fmt.Errorf("expected to find idx value of %d on some field, but found none", i)
}
return nil, fmt.Errorf("expected sequential indeces for map, but index %d is missing", i)

}
}

return idxTagVals, nil
}
6 changes: 3 additions & 3 deletions menu/idx_test.go → menu/fields_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,12 @@ func TestGetOrderedFields(t *testing.T) {
for _, tc := range tb.batch {
t.Run(tc.name, func(t *testing.T) {
rType := reflect.TypeOf(tc.input)
tags, err := getOrderedFields(rType)
orderedFields, err := getOrderedFields(getFields(rType))
if (err != nil) != tc.wantErr {
t.Errorf("got unexpected error: %v", err)
}
if !maps.Equal(tags, tc.expected) {
t.Errorf("expected: %v, got: %v", tc.expected, tags)
if !maps.Equal(orderedFields, tc.expected) {
t.Errorf("expected: %v, got: %v", tc.expected, orderedFields)
}
})
}
Expand Down
88 changes: 0 additions & 88 deletions menu/idx.go

This file was deleted.

78 changes: 47 additions & 31 deletions menu/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type EndState struct {
type Model struct {
// MENU STATE
// fields which can be edited; populated dynamically
data structData
menuFields []menuField
options MenuOptions
state *state
Expand Down Expand Up @@ -52,41 +53,40 @@ func (m *Model) getFieldUnderCursor() *menuField {
// of either of the indicator constants used to define exception lists.
// The Black() and White() functions exist as convenience wrappers to
// provide this functionally.
func NewMenu(i any, exceptions ...string) (Model, error) {
return generateNewMenu(i, nil, exceptions...)
func NewMenu(structlyPtr any, exceptions ...string) (Model, error) {
v, err := validateStructPtr(structlyPtr)
if err != nil {
return Model{}, err
}

return generateNewMenu(v, nil, exceptions...)
}

// NewMenuWithOptions operates just as NewMenu does, but exposes
// a parameter for passing a list of options. Because a call of
// this function is necessarily deliberate, it will helpfully
// return an error if no options are passed in.
func NewMenuWithOptions(structlyPtr any, options *MenuOptions, list ...string) (Model, error) {
if options == nil {
return Model{}, fmt.Errorf("new menu requested with options, but no options were provided")
}
return generateNewMenu(structlyPtr, options, list...)
}

// generateNewMenu validates and creates a new struct menu from the given
// parameters. If custom options are not provided, the menu will fall back
// to defaults.
func generateNewMenu(obj any, options *MenuOptions, exceptions ...string) (Model, error) {
// if fieldList is empty, all fields are exposed to users; otherwise, it is used as a whitelist.
// if bool parameter 'asBlacklist' is 'true', the fieldList is used as a blacklist instead of a whitelist.
m := Model{}

t := reflect.TypeOf(obj)
v := reflect.ValueOf(obj)
if t.Kind() == reflect.Pointer {
t = t.Elem()
v = v.Elem()
} else {
return m, fmt.Errorf("obj should be a pointer to struct, so as to have addressable fields")
if options == nil {
return m, fmt.Errorf("new menu requested with options, but no options were provided")
}
if t.Kind() != reflect.Struct {
return m, fmt.Errorf("input obj found not to be a struct")

v, err := validateStructPtr(structlyPtr)
if err != nil {
return m, err
}
m = Model{

return generateNewMenu(v, options, list...)
}

// generateNewMenu expects a reflect.Value validated as a struct value and
// generates a new menu model from the given parameters. If custom options
// are not provided, the menu will fall back to defaults.
func generateNewMenu(v reflect.Value, options *MenuOptions, exceptions ...string) (Model, error) {
m := Model{
data: structData{},
menuFields: []menuField{},
options: *NewMenuOptions(),
state: &state{
Expand All @@ -97,12 +97,13 @@ func generateNewMenu(obj any, options *MenuOptions, exceptions ...string) (Model
QuitWithCancel: false,
},
}
m.data.init(v)

if options != nil {
m.options = *options
}

orderedFields, err := getOrderedFields(t)
orderedFields, err := getOrderedFields(m.data.fields)
if err != nil {
return m, err
}
Expand All @@ -117,7 +118,7 @@ func generateNewMenu(obj any, options *MenuOptions, exceptions ...string) (Model
if !ok {
return m, fmt.Errorf("could not resolve struct field to display by declaration index %d", i)
}
field := t.Field(j)
field := m.data.fields[j]

if len(exceptions) != 0 {
switch exceptionListIndicator {
Expand All @@ -136,7 +137,7 @@ func generateNewMenu(obj any, options *MenuOptions, exceptions ...string) (Model
}
}

fieldVal := v.FieldByName(field.Name)
fieldVal := m.data.values[j]
if !fieldVal.CanSet() {
log.Printf("Warning: Field '%s' left unexposed (cannot be set; unexported or not addressable).\n", field.Name)
continue
Expand Down Expand Up @@ -174,12 +175,27 @@ func generateNewMenu(obj any, options *MenuOptions, exceptions ...string) (Model
return m, nil
}

func (m Model) ParseStruct(obj any) error {
v := reflect.ValueOf(obj)
if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct {
return fmt.Errorf("expected a pointer to a struct, got %v", v.Kind())
// validateStructPtr takes in an interface and ensures that
// it is a pointer to a struct type before returning then
// returning the struct as a reflect.Value.
func validateStructPtr(i any) (reflect.Value, error) {
v := reflect.ValueOf(i)
if v.Kind() != reflect.Pointer {
return v, fmt.Errorf("input interface should be a pointer to a struct, so as to have addressable fields")
}
v = v.Elem()
if v.Kind() != reflect.Struct {
return v, fmt.Errorf("input ptr found not to point to a struct")
}

return v, nil
}

func (m Model) ParseStruct(structlyPtr any) error {
v, err := validateStructPtr(structlyPtr)
if err != nil {
return err
}

for _, f := range m.menuFields {
field := v.FieldByName(f.name)
Expand Down