charmbracelet / bubbletea

A powerful little TUI framework 🏗
MIT License
26.57k stars 767 forks source link

Add a way to prevent tea.Quit from terminating the program #472

Closed aschey closed 1 year ago

aschey commented 1 year ago

tl;dr: POC of the proposed feature

link to Slack thread where this was originally discussed

I'm working on bubbleprompt, a library very similar to the popular go-prompt. It essentially acts as a REPL where users can run custom actions on submit. In bubbleprompt, I allow consumers of the library to supply an arbitrary tea.Model when they submit a command. When this child model sends tea.Quit, I need to prevent it from terminating the entire tea.Program and instead return control to the parent model.

Currently, I do this using reflection to check if the child model returns tea.Quit and prevent it from sending it if so. It looks something like this:

model, cmd := childModel.Update(msg)
if reflect.ValueOf(cmd).Pointer() == reflect.ValueOf(tea.Quit).Pointer() {
    // Don't send the command to the Bubble Tea runtime
    return
{

However, if the quit command is wrapped in tea.Batch or any similar construct, this gets substantially trickier.

To solve this problem, I propose adding a tea.onQuit event handler with a signature such as onQuit func() QuitBehavior. If the user supplies it, this function will run whenever the event loop receives a tea.quitMsg. The quit handler will return whether the program should shut down or not.

QuitBehavior is just an enum with two values: tea.Shutdown and tea.PreventShutdown. We could just use a bool instead of a custom enum, but I think that would be rather confusing to the user - does true mean shutdown or does false mean shutdown? If onQuit returns tea.Shutdown, then Bubble Tea will handle the message just as it currently does, but if it returns tea.PreventShutdown, then we should treat the tea.quitMsg as a normal message and forward it to the user's update handler for further processing. This would require making tea.quitMsg public so that applications could check for it.

A minimal usage example would look like this:

type model struct {
    child *tea.Model
}

var shutdown bool = false

func onQuit() tea.QuitBehavior {
    if shutdown {
        return tea.Shutdown
    } else {
        return tea.PreventShutdown
    }
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
        case tea.KeyMsg:
            switch msg.Type {
            // If we received a ctrl+c or escape, the user requested the entire program to shut down
            // Set shutdown to true so our quit handler tells the runtime to shut down
            case tea.KeyCtrlC, tea.KeyEsc:
                shutdown = true
                return m, tea.Quit
            }

        // If we received a shutdown message, onQuit returned tea.PreventShutdown which means the 
        // child model initiated the shutdown and our onQuit function prevented the runtime from shutting down.
        // Bubble Tea instead passed the QuitMsg along to our update function so we can handle it however we please.
        case tea.QuitMsg:
            m.child = nil
    }

    if m.child != nil {
        return m.child.Update(msg)
    }
    return doSomeOtherAction(msg);
}

func Run(child *tea.Model) {
    m := model{child}
    p := tea.NewProgram(m, tea.WithOnQuit(onQuit))
    p.Start();
}

I don't love the use of a global variable here, but I think it's the easiest way to handle this without introducing additional complexity by adding something like another special command that bypasses the onQuit handler and always shuts the program down. That feels like a heavy solution for this rather niche use case, so I'm trying to keep the proposed changes to Bubble Tea minimal here.

The implementation is rather simple, so I went ahead and made a quick POC. Happy to make a PR for this after hearing any feedback. Thanks!

bashbunni commented 1 year ago

Hey @aschey,

one way you could do this is to have your child return the parent model when a specific key is pressed (or some other IO has finished). You can save the state of the parent model to a global variable before changing to the child model if you want to pick up where you left off. Alternatively, you could have it create a new parent model that you return when you want to switch back to the parent model. It just depends on your particular case.

Let me know if this is a viable solution for you!

aschey commented 1 year ago

Hey @bashbunni thanks for the suggestion. In my case, it would be ideal if the consumers of bubbleprompt could supply any arbitrary model without them having to make any changes specific to how bubbleprompt uses it. This is both to make it easier for consumers to use the library and also for what I think will be a common use case - users may want to reuse their models between running inside bubbleprompt and running as a standalone CLI, in which case their models would need to behave normally without running inside of a parent model.

aschey commented 1 year ago

Also added the Slack link where this was originally discussed to the post

muesli commented 1 year ago

As discussed on Slack, I think this makes sense and we should add such a "callback" that lets the program prevent an app shutdown. A common use case for this would be presenting the user with a modal save dialog in case there are unsaved changes in an application.

aschey commented 1 year ago

Oh, that's a use case I hadn't considered. In that case, perhaps it makes sense to pass the model as a parameter to the callback so the user could do some kind of check on it like so:

func onQuit(m tea.Model) tea.QuitBehavior {
    appModel := m.(myModel)
    if appModel.pendingChanges {
        return tea.PreventShutdown
    } else {
        return tea.Shutdown
    }
}
bashbunni commented 1 year ago

Also related request: https://github.com/charmbracelet/bubbletea/pull/352#pullrequestreview-1109933756