diff --git a/README.md b/README.md index 8e8dca3..58e4194 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,17 @@ ![structly-example](https://github.com/user-attachments/assets/6effe862-21c1-472a-85ca-5b815580807c) -Structly is a Go library of bubbletea models leveraging the power of the `reflect` package to expose structs -as forms and menus directly to CLI users, allowing them to edit fields with primitive types. +A powerful bubbletea component leveraging Go's `reflect` package to expose +structs as menus for input by CLI users. Enjoy the convenience of managing +a single type containing multiple fields of user input, optionally coupled +with related fields reserved for your business logic. ## Motivation -I built Structly just as soon as I realized that I needed an easy, all-in-one method to ask for user form input through a CLI. -You may wonder, _"Isn't that what Charm's `huh?` is for?"_ +I first built Structly just as soon as I realized that I needed an efficient means of prompting users for form input through a CLI. Ever since, I have continued to improve its efficiencies and capabilities. + +You might be wondering, _"Why not use Charm's `Huh?`?"_ + But while [huh](https://github.com/charmbracelet/huh) is great, Structly aims to solve an albeit similar but separate set of problems. Structly's methodology revolves (unsurprisingly) entirely around structs. This provides the following benefits: @@ -20,23 +24,13 @@ Structly's methodology revolves (unsurprisingly) entirely around structs. This p - Blacklisting fields from menu exposure means relevant information can be tightly coupled with user inputs - Boilerplate is drastically reduced - Default values for fields are simply pulled from the struct instance provided at render time - - Declaration of a form is as familiar and succinct as defining a struct type and working with an instance of that, as opposed to calling a great amount of methods + - Declaration of a menu is as familiar and succinct as defining a struct type and working with an instance of that, as opposed to calling a great amount of methods -I personally use Structly to expose CLI configurations to users so that they can set those -values easily through the CLI, and then I can save the result using `json` tags also set on the -struct fields. +I personally use Structly to expose CLI configurations to users so that they can modify those values easily through the CLI. Then, I can save the result simply by using `json` tags also set on the struct fields. ## Usage -As of right now, the only types compatible for user-editable fields are: - -- Strings -- Integers -- Booleans - -The repo contains an example of how to use the package within `./example/default/main.go`. Let's walk through it! - -### Step 1: Import the menu package +To get started, import the `menu` package: ```bash go get github.com/bntrtm/structly @@ -46,196 +40,45 @@ go get github.com/bntrtm/structly import "github.com/bntrtm/structly/menu" ``` -### Step 2: Establish the struct you wish to expose to the user - -In this struct, we build out a list of potential fields on a theoretical -job application to illustrate the idea. - -**What To Know**: - -- The `smname` tag establishes the title and formatting of the field. If the tag is not present, - the menu will fall back to the default name of the struct field itself. For example, you'll - see in the above demonstration that the `Email` field renders as we would expect despite the - lack of the `smname` tag. -- The `smdes` tag renders an optional description when the user hovers their cursor over the field. -- We'll discuss the `BlacklistedField` bit in a minute. It will illustrate another feature! - -```go -// applicationForm holds fields typical of a job application. -type applicationForm struct { - FirstName string `smname:"First Name"` - LastName string `smname:"Last Name"` - Email string - PhoneNo int `smname:"Phone"` - Country string `smname:"Country"` - Location string `smname:"Location (City)"` - CanTravel bool `smname:"Travel" smdes:"Can you travel for work?"` - BlacklistedField string -} -``` - -### Step 3: Choose custom options to apply to your menu, if you desire +### Generating Menus -There are a number of custom options we could apply to our menu, such as -changing the ibeam cursor rendered during string input, or what the field -cursor might look like. +Robust documentation has been provided under [the examples directory](/examples). +You ought to [start with the introductory example](/examples/introduction/); it will walk you +through the basics, and then point you to more advanced resources via +other examples as you read! That said, here's a quick rundown of the +Structly experience as laid out in the initial example: -Here, we write a custom header to render during form interaction. -Before setting any values on -Because we're using custom options, we will have to initialize them -before setting any of the values on them. -_Never forget to do this! Zero values for menu options are NOT the defaults._ +1. **_Declare your struct._** You'll craft a struct just like any other; but perhaps with some Structly-exclusive struct tags and options that give you the best it has to offer. +2. **_Define a pointer to your struct._** Structly provides two fucntions you may use to generate a menu; each will expect a pointer to a struct as its first argument. +3. **_Generate your model._** Use one of the aforementioned functions, potentially passing in custom options, or an "Exception List" for blacklisting or whitelisting fields. +4. **_Use your model._** You'll render your menu in the terminal by running the model through a Bubble Tea program! Users will edit the fields you exposed from the struct, and when they're finished, you have their input stored within the same instance you defined in Step 2. +5. **_Do your thing._** You have the input you wanted. Now put it to good use! -```go - customMenuOptions := menu.NewMenuOptions() - customMenuOptions.SetHeader("Apply for this job: ") -``` - -### Step 4: Provide a struct to use during menu input - -Of course, you'll need a struct to expose to your CLI users! -Here, we simply declare an empty one, but don't worry: if you need to provide a struct -with non-zero values, you can also do that! The bubbletea model will keep those values -intact, showing them to users as existing values within the field. - -```go - newApplication := applicationForm{} -``` - -### Step 5: Initialize a menu - -Provide a pointer to your struct, a list of fields used as a whitelist or blacklist, and any -custom options. +> [!NOTE] +> +> As of right now, the only types compatible for user-editable fields are: +> +> - Strings +> - Integers +> - Booleans +> +> Be sure that any fields typed incompatibly are blacklisted using Structly's +> exception logic. -Hey, there's our `BlacklistedField` option we set earlier! See how our -argument passed to the `asBlacklist` parameter is set to `true`? It means that any fields -with the names given within the string slice to the left will be hidden from users. You can -see it in the demo above; the field doesn't show up! +### Structly is a Bubble -```go -configEditMenu, err := structly.InitialTModelStructMenu(&newApplication, []string{"BlacklistedField"}, true, customMenuOptions) - if err != nil { - log.Fatal("Trouble generating the application.") - } -``` +At the end of the day, Structly is providing you with a Bubble Tea model. +This makes Structly at least as predictable as any other "bubble." -### Step 6: Use the menu with the bubbletea package +If you aren't familiar with Charm's Bubble Tea package and their greater +ecosystem, you can see the following resources +for more information: -The menu is a bubbletea model! That is, it implements the bubbletea package! -We're now ready to run it through bubbletea and expose the menu to users to capture -their input! The result is the demo you saw above. - -```go -p := tea.NewProgram(configEditMenu) - if entry, err := p.Run(); err != nil { - log.Fatal("Trouble generating the application.") - } else { - if entry.(structly.TModelStructMenu).QuitWithCancel { - fmt.Printf("Canceled application.\n") - os.Exit(0) - } else { - err = entry.(structly.TModelStructMenu).ParseStruct(&newApplication) - if err != nil { - log.Fatal("Trouble generating the application.") - } - - // newApplication: "Wow, I feel like a new struct!" - } - if newApplication.FirstName == "" { - log.Fatal("ERROR: Missing First Name field!") - } - fmt.Printf("Thank you for applying, %s!\n", newApplication.FirstName) - time.Sleep(time.Second * 5) - os.Exit(0) - } -``` - -You have now captured user input for one or more fields using the `structly` package! -Do what you need with these new values. In the demo, our program -prints the name of the applicant after applying. +- [Charm Bubble Tea](charm.land/bubbletea) +- [Charm Bubbles](charm.land/bubbles) +- [About Charm](charm.land/) ## Advanced Features -### The `idx` Tag - -Great! We can declare the shape of user input using a struct. But what if I'm a -sucker for memory performance? If we must declare the fields of an input struct -in the order by which we want them displayed to users, we may face a tradeoff in -the form of a suboptimal memory layout on that struct. - -See, for example, our `applicationForm` struct from earlier. Imagine if we -wanted to display another bool-type option, `ConsentToSMS`, after the `PhoneNo` -field, but before the `Country` field. Because struct fields in Go sit in -memory within a contiguous block, we end up with needless padding in two places, -generated by Go for the sake of making each field easily addressable by the CPU: - -```go -// assume we're on a 64-bit system for this example -type applicationForm struct { - // ... - PhoneNo int - ConsentToSMS bool // 1 byte - // [ PADDING ] // 7 bytes - Country string - // ... - CanTravel bool // 1 byte - // [ PADDING ] // 7 bytes - BlacklistedField string -} -``` - -Go is adding 7 bytes of padding after each boolean-type struct field -by design, but we know that were we to pair those boolean fields together -(preferably at the end of the struct), our memory layout would be far more -optimal: Go would be adding 6 bytes of padding, rather than 14! Yet, because -of our selfish desire for a more sensible user form that asks for a user's -consent to receive SMS text messages _after_ inquiring about their phone number, -we end up wasting a total of 8 precious bytes! - -Is there anyone who could help us? - -Enter the `idx` tag! This tag allows us to declare our struct fields in whatever -more memory-performant way we desire, while telling `structly`'s bubbletea -model what order we actually want to display them in. - -```go -// applicationForm holds fields typical of a job application. -type applicationForm struct { - FirstName string `idx:"0"` - LastName string `idx:"1"` - Email string `idx:"2"` - Country string `idx:"5"` - Location string `idx:"6"` - BlacklistedField string `idx:"8"` - PhoneNo int `idx:"3"` - ConsentToSMS bool `idx:"4"` - CanTravel bool `idx:"7"` -} -``` - -Sure, the order of display may become a bit less readable at a glance by us -developers within the source code, but we'll have to accept _some_ tradeoff -in the end, right? It's just nice to be able to choose which tradeoff we're -willing to accept. - -The rules to using the `idx` tag are strict, but simple: - -- **All or Nothing**: if you use it on one field, use it on all fields. -- **Start at 0**: the values must start at 0 -- **Keep sequence**: the values must not break sequence. - - No values may be skipped. - - No two values may be the same. - -To observe the `idx` tag in action, run the example at `./example/withIDX/main.go`! -You'll find that it has the same behavior as seen in the `default` example, -despite the reordered fields under the `applicationForm` struct. - -This is the power of the `idx` tag! - -You can also see a demonstration of just the behavior itself by running the -subtest under `idx_test.go` dedicated to that using the following command in -your terminal: - -```bash -go test -run TestIDXMemoryLayout -v -``` +[Better Memory Management with the 'idx' tag](/examples/theIdxTag) +[Blacklisting fields from render with 'Exceptions'](/examples/exceptions/) diff --git a/example/withIDX/main.go b/example/withIDX/main.go deleted file mode 100644 index 226b1a0..0000000 --- a/example/withIDX/main.go +++ /dev/null @@ -1,76 +0,0 @@ -package main - -import ( - "fmt" - "log" - "os" - "time" - - "github.com/bntrtm/structly/menu" - tea "github.com/charmbracelet/bubbletea" -) - -// STEP 1: Establish the struct you wish to expose to the user. - -// applicationForm holds fields typical of a job application. -type applicationForm struct { - FirstName string `smname:"First Name" idx:"0"` - LastName string `smname:"Last Name" idx:"1"` - Email string `idx:"2"` - Country string `smname:"Country" idx:"4"` - Location string `smname:"Location (City)" idx:"5"` - BlacklistedField string `idx:"7"` - PhoneNo int `smname:"Phone" idx:"3"` - CanTravel bool `smname:"Travel" smdes:"Can you travel for work?" idx:"6"` -} - -func main() { - // STEP 2: Choose custom options to apply to your menu, if you desire. - // Ensure that if you use custom options, you use NewMenuOptions(), - // so that their values are initialized to sane defaults before use! - customMenuOptions := menu.NewMenuOptions() - customMenuOptions.SetHeader("Apply for this job: ") - - // STEP 3: Provide a struct to use. - // Don't worry, if you need to provide a struct with non-zero - // values, you can also do that! The tea model will keep those - // values intact. - newApplication := applicationForm{} - // STEP 4: Initialize a menu! - // Provide a pointer to your struct, blacklisted or - // whitelisted fields, and any custom options. - - // NOTE: Black() and White() exist as convenience wrappers to satisfy - // validation logic for exceptions under the hood. - model, err := menu.NewMenuWithOptions(&newApplication, customMenuOptions, menu.Black("BlacklistedField")...) - if err != nil { - log.Fatalf("Trouble generating the application: %s", err) - } - // STEP 5: Use the menu---a bubbletea model---with the bubbletea package! - // Here, we capture the result (our struct with user-entered values) - // as the tea.Model variable "entry". - p := tea.NewProgram(model) - if _, err := p.Run(); err != nil { - log.Fatalf("Trouble generating the application: %s", err) - } - if model.EndState.QuitWithCancel { - fmt.Printf("Canceled application.\n") - os.Exit(0) - } else { - err = model.ParseStruct(&newApplication) - if err != nil { - log.Fatalf("Trouble getting data from the application: %s", err) - } - - // Your struct is now full of user-entered values! - // Do what you need with it. - - // newApplication: "Wow, I feel like a new struct!" - } - if newApplication.FirstName == "" { - log.Fatal("ERROR: Missing First Name field!") - } - fmt.Printf("Thank you for applying, %s!\n", newApplication.FirstName) - time.Sleep(time.Second * 5) - os.Exit(0) -} diff --git a/examples/exceptions/README.md b/examples/exceptions/README.md new file mode 100644 index 0000000..34c1efd --- /dev/null +++ b/examples/exceptions/README.md @@ -0,0 +1,161 @@ +# Field Exceptions + +## Introduction + +### About + +In Structly, the term `exception` refers to a struct field marked as +blackisted or whitelisted from render. That is, when we have a struct +whose fields we wish to expose to a user, _except_ for one or more of +those fields (as they may be reserved only for business logic to handle), +we can blacklist them! + +There are two methods available for blacklisting fields, and they each +exist to serve different purposes: + +1. The `bl` Struct Tag +2. Exception Lists + +## The `bl` Struct Tag + +Using the `bl` struct tag on a single field in a struct will blacklist +it from all rendering. It is a means of indicating an exception at the +type level. + +Using `bl` is often the most simple option for indicating exceptions, +but it does mean that we can _never_ expose that field to users for input. +It is simply impossible by design. The tag is most useful in cases where +one or more struct fields are declared solely for the purpose of being +coupled with user inputs for business logic to be run later. + +When using the `bl` tag, its value never matters, so simply leave it empty +as a best practice. + +```go +type s struct { + s string `bl:""` // the 's' field will never render for user input + i int + b bool +} +``` + +## Exception Lists + +Exception lists allow us to tell the Structly model that we don't want +one or more specified fields (as indicated to by name) to be rendered +_for a single instance of a model render._ For reference, see the following +struct and note that the functions provided for menu generation offer an +`exceptionList` parameter for passing variadic arguments: + +```go +type s struct { + s string + i int + b bool +} + +func NewMenu(i any, exceptionList ...string) (Model, error) {...} + +func NewMenuWithOptions(structlyPtr any, options *MenuOptions, exceptionList ...string) (Model, error) {} + +``` + +Perhaps in some cases, we want to render all three of these fields +in a CLI, but in others, we don't. Rather than using the `bl` tag +as a permanent solution to the problem, we can simply pass an exception +list, appended with an indicator for blacklisting, to any of the +provided functions for menu generation: + +```go +menu.NewMenu(&myStruct, "s", "i", menu.BlacklistIndicator) +``` + +The `BlacklistIndicator` value is just a string with the value `BL` under +the hood. This also works, but is not recommended: + +```go +menu.NewMenu(&myStruct, "s", "i", "BL") +``` + +The indicator, which could just as well be the `WhitelistIndicator` (`WL`), +tells the Structly model which mode of exception should be used when +evaluating the list. This method does lose a bit of logical flow when reading +such function calls, however; it may be easy to assume that "BL" is the name +of a struct field that we want excepted, as opposed to what it is really there +for. With this in mind, Structly provides some convenience wrappers for providing +exception lists with this trouble abstracted away: + +```go +model, err := menu.NewMenu(&myStruct, Black("s", "i")...) +``` + +```go +model, err := menu.NewMenu(&myStruct, White("b")...) // whitelisting 'b' yields same result as above +``` + +## Further Notes + +### Non-Exclusivity + +Both of the aforementioned methods work in tandem. Were you to the following: + +```go +type s struct { + s string `bl:""` + i int + b bool +} + +menu.NewMenu(&myStruct, Black("i")...) +``` + +You would get a model whose render would consist only of the struct field `b` +exposed for user input. + +### Tag Interoperability + +The `bl` tag is **INCOMPATIBLE** with the `idx` tag for a single field. That is, +the tags will respect each other's presence so long as they never appear +together within the same full struct tag for a field. + +```go +// This is VALID; Structly will not error out. +// The model will expose b before i, and s will not be exposed. +type s struct { + s string `bl:""` + i int `idx:"1"` + b bool `idx:"0"` +} + +model, err := menu.NewMenu(&myStruct) + +// This is INVALID; Structly will error out +type s struct { + s string `bl:"" idx:"2"` + i int `idx:"1"` + b bool `idx:"0"` +} +``` + +### All Exception Values Respected + +You were made aware earlier that Structly evaluates the last element of +an exception list as the indicator for whether to define the exceptions +by whitelisting or blacklisting. + +In case you wondered, you need never worry over whether any one of your +exceptions matches an indicator value. In the following examples, the struct +field `BL` _is_ properly rendered: + +```go +type s struct { + BL string + s string + i int + b bool +} + +exampleOne, err := menu.NewMenu(&myStruct, "s", "i", "BL") + +exampleTwo, err := menu.NewMenu(&myStruct, Black("s", "i")...) +``` diff --git a/example/default/main.go b/examples/exceptions/main.go similarity index 57% rename from example/default/main.go rename to examples/exceptions/main.go index 4e45f42..f01554d 100644 --- a/example/default/main.go +++ b/examples/exceptions/main.go @@ -10,7 +10,15 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -// STEP 1: Establish the struct you wish to expose to the user. +// This example is provided to demonstrate the use of 'exceptions.' +// In the context of Structly, this term refers to how we can tell +// Structly which struct fields we don't want exposed to users. +// +// If you've not yet read through the 'introduction' example, it is +// best to start there. You can compare it with this example to +// get an understanding of where exception logic comes into play. + +// STEP 1: Declare the struct whose fields you wish to expose to users for input. // applicationForm holds fields typical of a job application. type applicationForm struct { @@ -21,34 +29,27 @@ type applicationForm struct { Country string `smname:"Country"` Location string `smname:"Location (City)"` CanTravel bool `smname:"Travel" smdes:"Can you travel for work?"` - BlacklistedField string + BlacklistedField string `bl:""` // + BlacklistMe int } func main() { - // STEP 2: Choose custom options to apply to your menu, if you desire. - // Ensure that if you use custom options, you use NewMenuOptions(), - // so that their values are initialized to sane defaults before use! - customMenuOptions := menu.NewMenuOptions() - customMenuOptions.SetHeader("Apply for this job: ") + // Declare options here, if you please... - // STEP 3: Provide a struct to use. - // Don't worry, if you need to provide a struct with non-zero - // values, you can also do that! The tea model will keep those - // values intact. + // STEP 2: Define your struct... newApplication := applicationForm{} - // STEP 4: Initialize a menu! - // Provide a pointer to your struct, blacklisted or - // whitelisted fields, and any custom options. + // STEP 3: Initialize a menu! + // + // This is the step wherein you may specify exception lists for your model. // // NOTE: Black() and White() exist as convenience wrappers to satisfy // validation logic for exceptions under the hood. - model, err := menu.NewMenuWithOptions(&newApplication, customMenuOptions, menu.Black("BlacklistedField")...) + model, err := menu.NewMenu(&newApplication, menu.Black("BlacklistMe")...) if err != nil { log.Fatalf("Trouble generating the application: %s", err) } - // STEP 5: Use the menu---a bubbletea model---with the bubbletea package! - // Here, we capture the result (our struct with user-entered values) - // as the tea.Model variable "entry". + // STEP 4: Run the bubbletea program. + // Blacklisted fields will not show up! p := tea.NewProgram(model) if _, err := p.Run(); err != nil { log.Fatalf("Trouble generating the application: %s", err) @@ -62,7 +63,7 @@ func main() { log.Fatalf("Trouble getting data from the application: %s", err) } - // Your struct is now full of user-entered values! + // Your struct is now full of user-input values! // Do what you need with it. // newApplication: "Wow, I feel like a new struct!" @@ -71,7 +72,7 @@ func main() { log.Fatal("ERROR: Missing First Name field!") } fmt.Printf("Thank you for applying, %s!\n", newApplication.FirstName) - time.Sleep(time.Second * 5) + time.Sleep(time.Second * 3) os.Exit(0) } } diff --git a/examples/introduction/README.md b/examples/introduction/README.md new file mode 100644 index 0000000..bb375bb --- /dev/null +++ b/examples/introduction/README.md @@ -0,0 +1,113 @@ +# Introduction to Structly + +Welcome to the introductory example for using Structly's menu package! +You should find some example code (with robust in-line documentation) +alongside this README file within the repository. + +Let's walk through it! + +### Step 1: Declare the struct you wish to expose to your CLI user + +In this struct, we build out a list of potential fields on a theoretical +job application to illustrate the idea. + +**What To Know**: + +- The `smname` tag establishes the title and formatting of the field. If the tag is not present, the menu will fall back to the default name of the struct field itself. For example, you'll see in the above demonstration that the `Email` field renders as we would expect despite the lack of the `smname` tag. +- The `smdes` tag renders an optional description when the user hovers their cursor over the field. + +```go +// applicationForm holds fields typical of a job application. +type applicationForm struct { + FirstName string `smname:"First Name"` + LastName string `smname:"Last Name"` + Email string + PhoneNo int `smname:"Phone"` + Country string `smname:"Country"` + Location string `smname:"Location (City)"` + CanTravel bool `smname:"Travel" smdes:"Can you travel for work?"` +} +``` + +### Step 3: Choose custom options to apply to your menu, if you desire + +There are a number of custom options we could apply to our menu, such as +changing the ibeam cursor ('|'') rendered during string input, or what the field +cursor might look like ('> '). + +For now, we'll just write a custom header to render during form interaction. +Custom options _must_ be initialized to defaults before setting any values +on them. Thankfully, if we use the provided function `NewMenuOptions()`, this +is already done for us under the hood! + +```go + customMenuOptions := menu.NewMenuOptions() + customMenuOptions.SetHeader("Apply for this job: ") +``` + +### Step 4: Define a struct to use during menu input + +Of course, you'll need a struct to expose to your CLI users! +Here, we simply define an empty one, but don't worry: if you need to provide a struct +with non-zero values, you can also do that! The Structly model will respect those values as defaults all the same. + +```go + newApplication := applicationForm{} +``` + +### Step 5: Initialize your model + +Provide a pointer to your struct instance, any custom options you've defined, and any applicable exception list. + +```go + model, err := menu.NewMenuWithOptions(&newApplication, customMenuOptions) + if err != nil { + log.Fatalf("Trouble generating the application: %s", err) + } +``` + +### Step 6: Use the model with the bubbletea package to render your menu + +The menu is a bubbletea model! That is, it implements the Bubble Tea `Model` interface. +We're now ready to run it through a new Bubble Tea program to expose the menu to users to capture their input! The result is the demo you saw above. + +```go + p := tea.NewProgram(model) + if _, err := p.Run(); err != nil { + log.Fatalf("Trouble generating the application: %s", err) + } else { + if model.EndState.QuitWithCancel { + fmt.Printf("Canceled application.\n") + os.Exit(0) + } else { + err = model.ParseStruct(&newApplication) + if err != nil { + log.Fatalf("Trouble getting data from the application: %s", err) + } + + // Your struct is now full of user-input values! + // Do what you need with it. + + // newApplication: "Wow, I feel like a new struct!" + } + if newApplication.FirstName == "" { + log.Fatal("ERROR: Missing First Name field!") + } + fmt.Printf("Thank you for applying, %s!\n", newApplication.FirstName) + time.Sleep(time.Second * 3) + os.Exit(0) +``` + +You have now captured user input for one or more fields using the `structly` package! +Do what you need with these new values. In the demo seen below, our program prints the name of the applicant after applying. + +![structly-example](https://github.com/user-attachments/assets/6effe862-21c1-472a-85ca-5b815580807c) + +## What's Next? + +Now that you've been introduced Structly, you ought to know about ways to improve the memory efficiency of the model you're generating. It may not be necessary right away, but because Structly uses the `reflect` package, it may behoove you to at least be aware of the tools Structly makes available to you to keep your program running as efficiently as possible. + +Otherwise, blacklisting with Structly's 'exceptions' logic is also one of its most useful features! + +[Read about the IDX Tag](/examples/theIdxTag) +[Read about Field Exceptions](/examples/exceptions) diff --git a/examples/introduction/main.go b/examples/introduction/main.go new file mode 100644 index 0000000..b511825 --- /dev/null +++ b/examples/introduction/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "fmt" + "log" + "os" + "time" + + "github.com/bntrtm/structly/menu" + tea "github.com/charmbracelet/bubbletea" +) + +// Welcome to Structly! +// This is an introductory example you can read through to get a +// feel for the basics. There's more you can read about that covers +// the way struct tags are leveraged for memory performance and +// field blacklisting, but this is a good place to get started! + +// STEP 1: Declare the struct whose fields you wish to expose to users for input. + +// applicationForm holds fields typical of a job application. +type applicationForm struct { + FirstName string `smname:"First Name"` + LastName string `smname:"Last Name"` + Email string + PhoneNo int `smname:"Phone"` + Country string `smname:"Country"` + Location string `smname:"Location (City)"` + CanTravel bool `smname:"Travel" smdes:"Can you travel for work?"` +} + +func main() { + // STEP 2: Choose custom options to apply to your menu, if you desire. + // Ensure that if you use custom options, you use NewMenuOptions(), + // so that their values are initialized to sane defaults before use! + customMenuOptions := menu.NewMenuOptions() + customMenuOptions.SetHeader("Apply for this job: ") + + // STEP 3: Define a struct to use. + // Don't worry, if you need to provide a struct with non-zero + // values, you can also do that! The Structly bubbletea model will + // respect those values as defaults all the same. + newApplication := applicationForm{} + + // STEP 4: Initialize a menu! + // Provide a pointer to your struct. + // If you chose not to define custom options, use the NewMenu function, instead. + model, err := menu.NewMenuWithOptions(&newApplication, customMenuOptions) + if err != nil { + log.Fatalf("Trouble generating the application: %s", err) + } + // STEP 5: Pass your new Structly model to the bubbletea package to generate your menu! + // After the bubbletea program exits, the instance you defined will contain the + // user-input values. + p := tea.NewProgram(model) + if _, err := p.Run(); err != nil { + log.Fatalf("Trouble generating the application: %s", err) + } else { + if model.EndState.QuitWithCancel { + fmt.Printf("Canceled application.\n") + os.Exit(0) + } else { + err = model.ParseStruct(&newApplication) + if err != nil { + log.Fatalf("Trouble getting data from the application: %s", err) + } + + // Your struct is now full of user-input values! + // Do what you need with it. + + // newApplication: "Wow, I feel like a new struct!" + } + if newApplication.FirstName == "" { + log.Fatal("ERROR: Missing First Name field!") + } + fmt.Printf("Thank you for applying, %s!\n", newApplication.FirstName) + time.Sleep(time.Second * 3) + os.Exit(0) + } +} diff --git a/examples/theIdxTag/README.md b/examples/theIdxTag/README.md new file mode 100644 index 0000000..3d5e665 --- /dev/null +++ b/examples/theIdxTag/README.md @@ -0,0 +1,94 @@ +# The `idx` Tag + +## What About Memory? + +So we can declare the shape of user input using a struct. But what if I'm a +sucker for memory performance? If we must declare the fields of an input struct +in the order by which we want them displayed to users, we may face a tradeoff in +the form of a suboptimal memory layout on that struct. + +See, for example, our `applicationForm` struct from the example(s). Imagine if we +wanted to display another bool-type option, `ConsentToSMS`, after the `PhoneNo` +field, but before the `Country` field. Because struct fields in Go sit in +memory within a contiguous block, we end up with needless padding in two places, +generated by Go for the sake of making each field easily addressable by the CPU: + +```go +// assume we're on a 64-bit system for this example +type applicationForm struct { + // ... + PhoneNo int + ConsentToSMS bool // 1 byte + // [ PADDING ] // 7 bytes + Country string + // ... + CanTravel bool // 1 byte + // [ PADDING ] // 7 bytes + CoverLetter string +} +``` + +Go is adding 7 bytes of padding after each boolean-type struct field +by design, but we know that were we to pair those boolean fields together +(preferably at the end of the struct), our memory layout would be far more +optimal: Go would be adding 6 bytes of padding, rather than 14! Yet, because +of our selfish desire for a more sensible user form that asks for a user's +consent to receive SMS text messages _after_ inquiring about their phone number, +we end up wasting a total of 8 precious bytes! + +Is there anyone who could help us? + +Enter the `idx` tag! This tag allows us to declare our struct fields in whatever +more memory-performant way we desire, while telling `structly`'s bubbletea +model what order we actually want to display them in. + +```go +// applicationForm holds fields typical of a job application. +type applicationForm struct { + FirstName string `idx:"0"` + LastName string `idx:"1"` + Email string `idx:"2"` + Country string `idx:"5"` + Location string `idx:"6"` + CoverLetter string `idx:"8"` + PhoneNo int `idx:"3"` + ConsentToSMS bool `idx:"4"` + CanTravel bool `idx:"7"` +} +``` + +Sure, the order of display may become a bit less readable at a glance by us +developers within the source code, but we'll have to accept _some_ tradeoff +in the end, right? It's just nice to be able to choose which tradeoff we're +willing to accept. + +The rules to using the `idx` tag are strict, but simple: + +- **All or Nothing**: if you use it on one field, use it on all fields. + - The exception here being on fields with the **incompatible** `bl` tag. +- **Start at 0**: the values must start at 0 +- **Keep sequence**: the values must not break sequence. + - No values may be skipped. + - No two values may be the same. + +To observe the `idx` tag in action, run the example at `./example/withIDX/main.go`! +You'll find that it has the same behavior as seen in the `default` example, +despite the reordered fields under the `applicationForm` struct. + +This is the power of the `idx` tag! + +You can also see a demonstration of just the behavior itself by running the +subtest under [idx_test.go](/menu/idx_test.go) dedicated to that using the following command in +your terminal: + +```bash +go test -run TestIDXMemoryLayout -v +``` + +## What's Next? + +Now that you've been introduced to the `idx` tag, you ought to know about its +interoperability with the `bl` tag, which is used for blacklisting fields action +the type level. + +[Read about Field Exceptions](/examples/exceptions) diff --git a/examples/theIdxTag/main.go b/examples/theIdxTag/main.go new file mode 100644 index 0000000..73b7ee6 --- /dev/null +++ b/examples/theIdxTag/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + "log" + "os" + "time" + + "github.com/bntrtm/structly/menu" + tea "github.com/charmbracelet/bubbletea" +) + +// STEP 1: Declare your struct... + +// applicationForm holds fields typical of a job application. +type applicationForm struct { + FirstName string `smname:"First Name" idx:"0"` + LastName string `smname:"Last Name" idx:"1"` + Email string `idx:"2"` + Country string `smname:"Country" idx:"4"` + Location string `smname:"Location (City)" idx:"5"` + CoverLetter string `idx:"7" smdes:"Tell us a little bit about you!"` + PhoneNo int `smname:"Phone" idx:"3"` + CanTravel bool `smname:"Travel" smdes:"Can you travel for work?" idx:"6"` +} + +func main() { + // Declare options here, if you please... + + // STEP 2: Define your struct... + newApplication := applicationForm{} + + // STEP 3: Initialize a menu... + model, err := menu.NewMenu(&newApplication, menu.Black("BlacklistedField")...) + if err != nil { + log.Fatalf("Trouble generating the application: %s", err) + } + + // STEP 5: Generate your menu! + // Thanks to the power of the 'idx' tag, our fields will render in the order + // that we specified in the declaration above. + p := tea.NewProgram(model) + if _, err := p.Run(); err != nil { + log.Fatalf("Trouble generating the application: %s", err) + } + if model.EndState.QuitWithCancel { + fmt.Printf("Canceled application.\n") + os.Exit(0) + } else { + err = model.ParseStruct(&newApplication) + if err != nil { + log.Fatalf("Trouble getting data from the application: %s", err) + } + + // Your struct is now full of user-entered values! + // Do what you need with it. + + // newApplication: "Wow, I feel like a new struct!" + } + if newApplication.FirstName == "" { + log.Fatal("ERROR: Missing First Name field!") + } + fmt.Printf("Thank you for applying, %s!\n", newApplication.FirstName) + time.Sleep(time.Second * 3) + os.Exit(0) +} diff --git a/menu/idx.go b/menu/idx.go index 67fb925..c2ec4d5 100644 --- a/menu/idx.go +++ b/menu/idx.go @@ -6,31 +6,51 @@ import ( "strconv" ) -// getStructIdxMap returns a map of `idx` tag values which -// correspond 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. +// 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 first 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 to not have the tag. +// 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. // -// Where validation fails, a nil map and error are -// returned. -func getStructIdxMap(t reflect.Type) (map[int]int, error) { - wantIdx := false +// 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. +// +// 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{} for i := 0; i < t.NumField(); i++ { field := t.Field(i) - tagValue, ok := field.Tag.Lookup("idx") - if ok && i == 0 { - wantIdx = true + _, 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) + } + continue + } + + // NOTE: at this point, can't possibly be blacklisted + if !wantIdx.isSet { + wantIdx.val = (len(idxTagVals) == 0 && isIndexed) + wantIdx.isSet = true } - if wantIdx { - if !ok { + if wantIdx.val { + if !isIndexed { return nil, fmt.Errorf("no `idx` tag found on struct field %s", field.Name) } idx, err := strconv.Atoi(tagValue) @@ -41,7 +61,7 @@ func getStructIdxMap(t reflect.Type) (map[int]int, error) { return nil, fmt.Errorf("value %d for `idx` tag on field %s already assigned to another field", val, field.Name) } idxTagVals[idx] = i - } else if ok { + } else if isIndexed { return nil, fmt.Errorf("unexpected `idx` tag found on field %s", field.Name) } diff --git a/menu/idx_test.go b/menu/idx_test.go index 1277bbe..83328c5 100644 --- a/menu/idx_test.go +++ b/menu/idx_test.go @@ -6,29 +6,31 @@ import ( "testing" ) -func TestGetStructIdxMap(t *testing.T) { - tests := []struct { +func TestGetOrderedFields(t *testing.T) { + type idxTest struct { expected map[int]int input any name string wantErr bool - }{ + } + + idxTestsIsolated := []idxTest{ { - name: "no idx tags returns empty with no error", + name: "no idx validation returns empty with no error", input: struct { - b bool s string i int + b bool }{}, expected: map[int]int{}, wantErr: false, }, { - name: "idx tags returns indeces as specified per field", + name: "idx validation returns indeces as specified per field", input: struct { - b bool `idx:"2"` - s string `idx:"0"` - i int `idx:"1"` + s string `idx:"2"` + i int `idx:"0"` + b bool `idx:"1"` }{}, expected: map[int]int{ 2: 0, @@ -38,21 +40,21 @@ func TestGetStructIdxMap(t *testing.T) { wantErr: false, }, { - name: "idx tags not starting at 0 returns nil with error", + name: "idx validation not starting at 0 returns nil with error", input: struct { - b bool `idx:"3"` - s string `idx:"1"` - i int `idx:"2"` + s string `idx:"3"` + i int `idx:"1"` + b bool `idx:"2"` }{}, expected: nil, wantErr: true, }, { - name: "idx tags out of sequence returns nil with error", + name: "idx validation out of sequence returns nil with error", input: struct { - b bool `idx:"3"` - s string `idx:"0"` - i int `idx:"1"` + s string `idx:"3"` + i int `idx:"0"` + b bool `idx:"1"` }{}, expected: nil, wantErr: true, @@ -60,8 +62,8 @@ func TestGetStructIdxMap(t *testing.T) { { name: "missing idx tag returns nil with error", input: struct { - b bool `idx:"2"` - s string + s string `idx:"2"` + b bool i int `idx:"1"` }{}, expected: nil, @@ -70,24 +72,95 @@ func TestGetStructIdxMap(t *testing.T) { { name: "missing idx on first field enforces non-presence", input: struct { - b bool - s string `idx:"0"` - i int `idx:"1"` + s string + i int `idx:"0"` + b bool `idx:"1"` + }{}, + expected: nil, + wantErr: true, + }, + } + idxTestsWithBlacklistTag := []idxTest{ + { + name: "idx validation skips bl-tagged field (first)", + input: struct { + s string `bl:""` + i int `idx:"0"` + b bool `idx:"1"` + }{}, + expected: map[int]int{ + 1: 2, + 0: 1, + }, + wantErr: false, + }, + { + name: "idx validation skips bl-tagged field (middle)", + input: struct { + s string `idx:"1"` + i int `bl:""` + b bool `idx:"0"` + }{}, + expected: map[int]int{ + 1: 0, + 0: 2, + }, + wantErr: false, + }, + { + name: "idx validation skips bl-tagged field (last)", + input: struct { + s string `idx:"1"` + i int `idx:"0"` + b bool `bl:""` + }{}, + expected: map[int]int{ + 1: 0, + 0: 1, + }, + wantErr: false, + }, + { + name: "errors with incompatible tags idx and bl", + input: struct { + s string `idx:"2" bl:""` + i int `idx:"0"` + b bool `bl:""` }{}, expected: nil, wantErr: true, }, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - rType := reflect.TypeOf(tc.input) - tags, err := getStructIdxMap(rType) - if (err != nil) != tc.wantErr { - t.Errorf("got unexpected error: %s", err) - } - if !maps.Equal(tags, tc.expected) { - t.Errorf("expected: %v, got: %v", tc.expected, tags) + tests := []struct { + name string + batch []idxTest + }{ + { + // test that idx-tagged structs work in isolation + name: "idx tag logic (isolated)", + batch: idxTestsIsolated, + }, + { + // test idx tag interoperability with bl tag + name: "idx tag logic (bl tag compatibility)", + batch: idxTestsWithBlacklistTag, + }, + } + + for _, tb := range tests { + t.Run(tb.name, func(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) + 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) + } + }) } }) } diff --git a/menu/model.go b/menu/model.go index 70d065e..68d4769 100644 --- a/menu/model.go +++ b/menu/model.go @@ -102,7 +102,7 @@ func generateNewMenu(obj any, options *MenuOptions, exceptions ...string) (Model m.options = *options } - orderedFields, err := getStructIdxMap(t) + orderedFields, err := getOrderedFields(t) if err != nil { return m, err } @@ -112,16 +112,10 @@ func generateNewMenu(obj any, options *MenuOptions, exceptions ...string) (Model return m, err } - for i := 0; i < t.NumField(); i++ { - var j int - if len(orderedFields) == 0 { - j = i - } else { - var ok bool - j, ok = orderedFields[i] - if !ok { - return m, fmt.Errorf("could not resolve struct field to display by declaration index %d", i) - } + for i := 0; i < len(orderedFields); i++ { + j, ok := orderedFields[i] + if !ok { + return m, fmt.Errorf("could not resolve struct field to display by declaration index %d", i) } field := t.Field(j)