Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interaction with bubbletea #178

Open
flowchartsman opened this issue Jul 6, 2023 · 10 comments
Open

Interaction with bubbletea #178

flowchartsman opened this issue Jul 6, 2023 · 10 comments

Comments

@flowchartsman
Copy link

Perhaps similar in spirit to some aspects of #116, I think you could provide a set of simple, interactive inputs using TUI elements provided by bubbletea and bubbles. There are a couple of different approaches that you could take, or combine. A compelling one is a technique I saw used in chezmoi where templates receive a funcmap that provides functions which, when executed, create a bubbles widget on the fly and interpolate its value. Here's a quick (and dirty) prototype.

package main

import (
	"bytes"
	"fmt"
	"log"
	"os"
	"text/template"

	"github.com/charmbracelet/bubbles/textinput"
	tea "github.com/charmbracelet/bubbletea"
)

func main() {
	tmpl, err := template.New("").Funcs(interactiveFns()).Parse(os.Args[1])
	if err != nil {
		log.Fatalf("parsing tempalte: %v", err)
	}
	var buf bytes.Buffer
	tmpl.Execute(&buf, nil)
	os.Stdout.Write(buf.Bytes())
}

type interactiveCtx struct {
	values map[string]any
}

func interactiveFns() template.FuncMap {
	c := &interactiveCtx{
		values: map[string]any{},
	}
	return template.FuncMap{
		"stringInput": stringInput(c),
	}
}

func stringInput(c *interactiveCtx) func(string) (any, error) {
	return func(inputPrompt string) (any, error) {
		if val, found := c.values[inputPrompt]; found {
			return val, nil
		}
		p := tea.NewProgram(initialModel(inputPrompt))
		finalModel, err := p.Run()
		if err != nil {
			return nil, err
		}
		value := finalModel.(textInputModel).Value()
		c.values[inputPrompt] = value
		return value, nil
	}
}

type (
	errMsg error
)

type textInputModel struct {
	prompt    string
	textInput textinput.Model
	err       error
}

func initialModel(inputPrompt string) textInputModel {
	ti := textinput.New()
	ti.Focus()
	ti.CharLimit = 156
	ti.Width = 20

	return textInputModel{
		prompt:    inputPrompt,
		textInput: ti,
		err:       nil,
	}
}

func (m textInputModel) Init() tea.Cmd {
	return textinput.Blink
}

func (m textInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	var cmd tea.Cmd

	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.Type {
		case tea.KeyEnter, tea.KeyCtrlC, tea.KeyEsc:
			return m, tea.Quit
		}

	case errMsg:
		m.err = msg
		return m, nil
	}

	m.textInput, cmd = m.textInput.Update(msg)
	return m, cmd
}

func (m textInputModel) View() string {
	return fmt.Sprintf(
		"%s %s",
		m.prompt,
		m.textInput.View(),
	) + "\n"
}

func (m textInputModel) Value() string {
	return m.textInput.Value()
}

Example input/output

go run main.go 'User {{stringInput "First Name"}} {{stringInput "Last Name"}} has a first name of {{
stringInput "First Name"}}'
First Name > Rob                  
Last Name > Robertson            
User Rob Robertson has a first name of Rob
@bitfield
Copy link
Owner

bitfield commented Jul 6, 2023

Thanks for the suggestion, @flowchartsman! It sounds interesting. How, specifically, do you think the script package should provide support for this idea? Maybe you could give an example of a script-based program that shows what you have in mind.

@flowchartsman
Copy link
Author

flowchartsman commented Jul 7, 2023

So, thinking on it some more, the template execution solution is maybe a bit too "clever"; the mean idea is around inputs and template integration, where maybe you have something functions like Input(inputName string, <options...>) or InputForm(formName, <options>) which immediately spawn interactive inputs and return, respectively, equivalents of string or map[string]string. These would also store their values in a global user input value where individual user inputs can be accessed through functions attached to all templates.

Then you could do something like:

newSource := InputForm("source", 
    inputStr("name", "Source Name"),
    inputStr("topic", "Output Topic"),
    inputSelect("sourcetype", "Source Type", "option1", "option2"))
script.Exec("pulsar-admin sources create --name {{form source name}} --destination-topic {{form source topic}} --type {{form source sourcetype}}").WriteFile("create_"+newSource["name"]+".log)

Where user input can be accessed both through the newSource variable in code context and {{form source ...}} in the templates. This is just an untested sketch, of course, but that's the general idea)

You could even conceivably shorten the template portions by tracking a map of named inputs and creating a list of functions for accessing them on template execution so that it becomes

script.Exec("pulsar-admin sources create --name {{source name}} --destination-topic {{source topic}} --type {{source sourcetype}}").WriteFile("create_"+newSource["name"]+".log)

@flowchartsman
Copy link
Author

flowchartsman commented Jul 7, 2023

Yet another option would be a two-stage approach with multiple delimiter types, if you wanted to keep user inputs visually separate like:

script.Exec("pulsar-admin sources create --name ${source name} --destination-topic ${source topic} --type ${source sourcetype}").WriteFile("create_"+newSource["name"]+".log)

@bitfield
Copy link
Owner

bitfield commented Jul 7, 2023

I think you could do something like your first example in three steps:

  1. Get the user to fill out the form
  2. Render a template for the pulsar-admin command in the context of the form data
  3. Run the complete command using script.Exec

That's actually more general and flexible than prompting the user for inputs on the fly, isn't it—because then the data for the template could come from many different sources, of which interactive forms might be just one.

One thing I'm having difficulty with is seeing where this interactive form-filling fits in with everything else that script does. It sounds like it could be a useful add-on, but I'm not sure if there's an obvious way to integrate it into the model of "everything is a method on a pipe". What do you think?

@flowchartsman
Copy link
Author

flowchartsman commented Jul 7, 2023

I'm not sure if there's an obvious way to integrate it into the model of "everything is a method on a pipe". What do you think?

Yes, I think the template angle fits a bit more naturally for the "anywhere in the pipe" situation, though it's less in keeping with the functional-style shell pipe analogy, since it introduces side-effects. I'm am trying to think of any good examples from unixland where steps in a pipeline break out to ask for input, but I'm not actually coming up with any, which might be a good argument to have it apart from the pipeline.

Having said that, if you think of this as a library that wants to provide a clean API to do things users might otherwise want to do with shell scripts, then interactivity is still useful to provide, since it's something a lot of users want to have in scripts, but avoid because the boilerplate to do it in a script is kind of a pain. In fact, when I was filing this issue, I was actually inpspired in part by the gum tool, which tries to bridge that gap, and lets script writers do things like COMMIT_TYPE=$(gum choose "fix" "feat" "docs" "style" "refactor" "test" "chore" "revert"). I can see this being super useful for personal tooling, especially with flag overriding, and if the design is clean, it could really expand the types of programs that could be written with thescript, without changing the core workflow.

So, while it might not have a home as a pipeline component (other than a source maybe?), interaction could find a place at initialization or between steps as a way to guide the flow of the "script".

It sounds like it could be a useful add-on

It could certainly work as an add-on, but for my part I'd be more inclined to just wrap the package into one of my own to give me access to all of the tools at once, so that I could have a go-to dependency for most of the tasks of this type that I want to write.

@flowchartsman
Copy link
Author

flowchartsman commented Jul 7, 2023

the other inspiration is that I wrote a tool recently to fill a similar niche to shell to let me easily automate repetitive tasks, and I found myself wanting an way to do inputs too, so that I could just reach for that one library any time I needed. I also made the decision to have it be YAML-driven, but I didn't like the requirement of an extra file, when what I really wanted was a simple way to chain things together.

Then something in my memory clicked, and I remembered that shell exists, so I thought I might propose an extension here instead and get the best of both worlds :)

Edit: I also wanted to add that I definitely see your point regarding the general pipeline nature of the library and keeping that core lean. So if you don't think this kind of extension has a place, no harm no foul. If anything, it can help clarify the mission statement about what is and what is not viable for the package.

@bitfield
Copy link
Owner

bitfield commented Jul 8, 2023

It seems clear that there's a fairly common general requirement for 'getting user input as part of a pipeline', even if that's as simple as 'press any key to continue', or as complex as 'select from the following interactive menu'.

I'd like to invite some specific design proposals for this, from whoever's interested or has ideas. The best way to do this is probably to comment with a script program you'd like to write, involving user input, showing how your proposed API would work in a realistic application.

@flowchartsman
Copy link
Author

flowchartsman commented Jul 9, 2023

So I've been doing some Pulsar work recently, and one of the things I wanted to do was to select a list of pulsar connectors to download locally, from the archive site, first by selecting from the available versions, then by selecting from the available connector files. With a goquery PR, which I've been kicking around, that might look something like:

const (
	archiveURL = `https://archive.apache.org/dist/pulsar/`
        connectorPfx = `https://archive.apache.org/dist/pulsar/pulsar-{{.}}/connectors/`
)
func main() {
	pVersions := regexp.MustCompile(`pulsar-(?P<pversion>\d+\.\d+.\d+)/`)
	script.Get(archiveURL).GoQuery(`a`).MatchRegexp(pVersions).ChooseOneFrom("{{.pversion}}").
                Get(connectorPfx).GoQuery(`a`).ChooseAnyFrom("{{.}}").ExecForEach("curl -sLO {{.}}").Stdout()
}

@bitfield
Copy link
Owner

Sounds interesting! Perhaps we could prototype this with gum?

@flowchartsman
Copy link
Author

I'm absolutely down to give it a shot. Will probably need a couple weeks before some time frees up for OS work, but I'm happy to prototype an implementation!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants