From b0f0062d32cc61dbbce4d9180c0e873f31602210 Mon Sep 17 00:00:00 2001 From: bntrtm Date: Thu, 9 Apr 2026 19:22:47 -0500 Subject: [PATCH 1/8] docs: update internal menu gen func Update input parameter name, error messages, remove outdated in-line docs --- menu/model.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/menu/model.go b/menu/model.go index 68d4769..8f5cd8f 100644 --- a/menu/model.go +++ b/menu/model.go @@ -70,21 +70,19 @@ func NewMenuWithOptions(structlyPtr any, options *MenuOptions, list ...string) ( // 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. +func generateNewMenu(ptr any, options *MenuOptions, exceptions ...string) (Model, error) { m := Model{} - t := reflect.TypeOf(obj) - v := reflect.ValueOf(obj) + t := reflect.TypeOf(ptr) + v := reflect.ValueOf(ptr) 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") + return m, fmt.Errorf("ptr interface should be a pointer to a struct, so as to have addressable fields") } if t.Kind() != reflect.Struct { - return m, fmt.Errorf("input obj found not to be a struct") + return m, fmt.Errorf("input ptr found not to point to a struct") } m = Model{ menuFields: []menuField{}, From a0d757d76587238e42e1e9f7e7b64b7e4a62d6d9 Mon Sep 17 00:00:00 2001 From: bntrtm Date: Thu, 9 Apr 2026 19:27:30 -0500 Subject: [PATCH 2/8] refactor: pull type from reflect value --- menu/model.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/menu/model.go b/menu/model.go index 8f5cd8f..31915e6 100644 --- a/menu/model.go +++ b/menu/model.go @@ -73,17 +73,15 @@ func NewMenuWithOptions(structlyPtr any, options *MenuOptions, list ...string) ( func generateNewMenu(ptr any, options *MenuOptions, exceptions ...string) (Model, error) { m := Model{} - t := reflect.TypeOf(ptr) v := reflect.ValueOf(ptr) - if t.Kind() == reflect.Pointer { - t = t.Elem() - v = v.Elem() - } else { + if v.Kind() != reflect.Pointer { return m, fmt.Errorf("ptr interface should be a pointer to a struct, so as to have addressable fields") } - if t.Kind() != reflect.Struct { + v = v.Elem() + if v.Kind() != reflect.Struct { return m, fmt.Errorf("input ptr found not to point to a struct") } + m = Model{ menuFields: []menuField{}, options: *NewMenuOptions(), @@ -100,6 +98,7 @@ func generateNewMenu(ptr any, options *MenuOptions, exceptions ...string) (Model m.options = *options } + t := v.Type() orderedFields, err := getOrderedFields(t) if err != nil { return m, err From b26e224ec5db85ff6e18e692776a29ffb4ff9233 Mon Sep 17 00:00:00 2001 From: bntrtm Date: Thu, 9 Apr 2026 19:31:12 -0500 Subject: [PATCH 3/8] chore: group tag validation logic with fields logic --- menu/fields.go | 82 ++++++++++++++++++++++++++ menu/{idx_test.go => fields_test.go} | 0 menu/idx.go | 88 ---------------------------- 3 files changed, 82 insertions(+), 88 deletions(-) rename menu/{idx_test.go => fields_test.go} (100%) delete mode 100644 menu/idx.go diff --git a/menu/fields.go b/menu/fields.go index 40d5772..dfd8e95 100644 --- a/menu/fields.go +++ b/menu/fields.go @@ -2,6 +2,7 @@ package menu import ( "fmt" + "reflect" "strconv" ) @@ -111,3 +112,84 @@ func (f *menuField) getFieldName() string { } return f.name } + +// getOrderedFields accounts for the presence of `idx` and `bl` +// tags 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 reflect.Type) (map[int]int, error) { + wantIdx := struct { + val bool + isSet bool + }{val: false, isSet: false} + + idxTagVals := map[int]int{} + blacklistCount := 0 + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + _, 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 +} diff --git a/menu/idx_test.go b/menu/fields_test.go similarity index 100% rename from menu/idx_test.go rename to menu/fields_test.go diff --git a/menu/idx.go b/menu/idx.go deleted file mode 100644 index 3c22cbf..0000000 --- a/menu/idx.go +++ /dev/null @@ -1,88 +0,0 @@ -package menu - -import ( - "fmt" - "reflect" - "strconv" -) - -// getOrderedFields accounts for the presence of `idx` and `bl` -// tags 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 reflect.Type) (map[int]int, error) { - wantIdx := struct { - val bool - isSet bool - }{val: false, isSet: false} - - idxTagVals := map[int]int{} - blacklistCount := 0 - for i := 0; i < t.NumField(); i++ { - field := t.Field(i) - - _, 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 -} From d67aef4e969b1d02e61364c9420f1b821b39ea00 Mon Sep 17 00:00:00 2001 From: bntrtm Date: Thu, 9 Apr 2026 20:01:59 -0500 Subject: [PATCH 4/8] feat!: add func dedicated to mapping fields with indeces directly This better separates the concern of reflect calls to retrieve the fields themselves from that of ordering the fields. --- menu/fields.go | 26 ++++++++++++++++++++------ menu/fields_test.go | 6 +++--- menu/model.go | 2 +- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/menu/fields.go b/menu/fields.go index dfd8e95..df597f8 100644 --- a/menu/fields.go +++ b/menu/fields.go @@ -113,11 +113,22 @@ func (f *menuField) getFieldName() string { return f.name } +// 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 to provide a map that defines the order by which each -// struct fields ought be rendered in the terminal. +// 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` +// 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 @@ -132,7 +143,7 @@ func (f *menuField) getFieldName() string { // blacklisted with the `bl` tag. // // Where validation fails, a nil map and error are returned. -func getOrderedFields(t reflect.Type) (map[int]int, error) { +func getOrderedFields(t map[int]*reflect.StructField) (map[int]int, error) { wantIdx := struct { val bool isSet bool @@ -140,8 +151,11 @@ func getOrderedFields(t reflect.Type) (map[int]int, error) { idxTagVals := map[int]int{} blacklistCount := 0 - for i := 0; i < t.NumField(); i++ { - field := t.Field(i) + 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") diff --git a/menu/fields_test.go b/menu/fields_test.go index 446ba16..0afc1eb 100644 --- a/menu/fields_test.go +++ b/menu/fields_test.go @@ -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) } }) } diff --git a/menu/model.go b/menu/model.go index 31915e6..404aec3 100644 --- a/menu/model.go +++ b/menu/model.go @@ -99,7 +99,7 @@ func generateNewMenu(ptr any, options *MenuOptions, exceptions ...string) (Model } t := v.Type() - orderedFields, err := getOrderedFields(t) + orderedFields, err := getOrderedFields(getFields(t)) if err != nil { return m, err } From 9acef169f4697028f84ef3c8d0a0d3ce87f16aa7 Mon Sep 17 00:00:00 2001 From: bntrtm Date: Thu, 9 Apr 2026 20:25:28 -0500 Subject: [PATCH 5/8] refactor: use dedicated ptr->struct-type validation func --- menu/model.go | 63 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/menu/model.go b/menu/model.go index 404aec3..80b15ed 100644 --- a/menu/model.go +++ b/menu/model.go @@ -52,8 +52,13 @@ 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 @@ -61,28 +66,25 @@ func NewMenu(i any, exceptions ...string) (Model, error) { // 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(ptr any, options *MenuOptions, exceptions ...string) (Model, error) { m := Model{} - v := reflect.ValueOf(ptr) - if v.Kind() != reflect.Pointer { - return m, fmt.Errorf("ptr interface should be a pointer to a struct, so as to have addressable fields") + if options == nil { + return m, fmt.Errorf("new menu requested with options, but no options were provided") } - v = v.Elem() - if v.Kind() != reflect.Struct { - return m, fmt.Errorf("input ptr found not to point to 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{ menuFields: []menuField{}, options: *NewMenuOptions(), state: &state{ @@ -171,12 +173,27 @@ func generateNewMenu(ptr 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) From bfa1bca061d640b2b5d26127f0652d4d13c92e06 Mon Sep 17 00:00:00 2001 From: bntrtm Date: Thu, 9 Apr 2026 20:30:13 -0500 Subject: [PATCH 6/8] feat: reduce reflect calls with getFields result With v.Field() calls now separated from tag validation as its own concern, it's time to use that refactor for all it's worth. --- menu/model.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/menu/model.go b/menu/model.go index 80b15ed..e858c9a 100644 --- a/menu/model.go +++ b/menu/model.go @@ -101,7 +101,8 @@ func generateNewMenu(v reflect.Value, options *MenuOptions, exceptions ...string } t := v.Type() - orderedFields, err := getOrderedFields(getFields(t)) + fields := getFields(t) + orderedFields, err := getOrderedFields(fields) if err != nil { return m, err } @@ -116,7 +117,7 @@ func generateNewMenu(v reflect.Value, options *MenuOptions, exceptions ...string if !ok { return m, fmt.Errorf("could not resolve struct field to display by declaration index %d", i) } - field := t.Field(j) + field := fields[j] if len(exceptions) != 0 { switch exceptionListIndicator { From b8ae5fbaaf3e94ff8c51861e91ba7e5ea512438d Mon Sep 17 00:00:00 2001 From: bntrtm Date: Thu, 9 Apr 2026 20:56:26 -0500 Subject: [PATCH 7/8] feat: store reflect data store values returned from reflect calls in a struct for the model to reference throughout its life; this can help to reduce reflect calls --- menu/fields.go | 22 ++++++++++++++++++++++ menu/model.go | 9 +++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/menu/fields.go b/menu/fields.go index df597f8..1a43a3a 100644 --- a/menu/fields.go +++ b/menu/fields.go @@ -113,6 +113,28 @@ 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 { diff --git a/menu/model.go b/menu/model.go index e858c9a..70a73a8 100644 --- a/menu/model.go +++ b/menu/model.go @@ -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 @@ -85,6 +86,7 @@ func NewMenuWithOptions(structlyPtr any, options *MenuOptions, list ...string) ( // 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{ @@ -95,14 +97,13 @@ func generateNewMenu(v reflect.Value, options *MenuOptions, exceptions ...string QuitWithCancel: false, }, } + m.data.init(v) if options != nil { m.options = *options } - t := v.Type() - fields := getFields(t) - orderedFields, err := getOrderedFields(fields) + orderedFields, err := getOrderedFields(m.data.fields) if err != nil { return m, err } @@ -117,7 +118,7 @@ func generateNewMenu(v reflect.Value, options *MenuOptions, exceptions ...string if !ok { return m, fmt.Errorf("could not resolve struct field to display by declaration index %d", i) } - field := fields[j] + field := m.data.fields[j] if len(exceptions) != 0 { switch exceptionListIndicator { From 899858aea187b1054900ecb3ba7fa66a69334485 Mon Sep 17 00:00:00 2001 From: bntrtm Date: Thu, 9 Apr 2026 20:58:07 -0500 Subject: [PATCH 8/8] feat: reduce reflect calls with values map --- menu/model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/menu/model.go b/menu/model.go index 70a73a8..2bb9d1c 100644 --- a/menu/model.go +++ b/menu/model.go @@ -137,7 +137,7 @@ func generateNewMenu(v reflect.Value, options *MenuOptions, exceptions ...string } } - 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