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

tea.Listen() to automatically listen on a chan tea.Msg #1135

Open
myaaaaaaaaa opened this issue Sep 11, 2024 · 4 comments
Open

tea.Listen() to automatically listen on a chan tea.Msg #1135

myaaaaaaaaa opened this issue Sep 11, 2024 · 4 comments
Labels
enhancement New feature or request

Comments

@myaaaaaaaaa
Copy link

Is your feature request related to a problem? Please describe.

Currently, tea.Cmds can only send a single tea.Msg before terminating, making things awkward when there is a need for a continuously running function that sends multiple tea.Msgs.

This can be seen in the realtime example, where a wrapper function waitForActivity() must be used to relay messages from listenForActivity().

Describe the solution you'd like

A tea.Listen(func(chan<- tea.Msg)) tea.Cmd function to accompany functions like tea.Batch() or tea.Every(). It should create a chan tea.Msg, pass it to the given function and run it in a separate goroutine, and relay messages that it receives on said channel to the Update() function.

Below is how the realtime example would be changed. Note how there is no longer a need to manually resend waitForActivity() commands or pass around a channel.

diff --git a/examples/realtime/main.go b/examples/realtime/main.go
index 4abddd3..1e1a71c 100644
--- a/examples/realtime/main.go
+++ b/examples/realtime/main.go
@@ -21,25 +21,15 @@ type responseMsg struct{}
-func listenForActivity(sub chan struct{}) tea.Cmd {
-	return func() tea.Msg {
-		for {
-			time.Sleep(time.Millisecond * time.Duration(rand.Int63n(900)+100)) // nolint:gosec
-			sub <- struct{}{}
-		}
-	}
-}
-
-// A command that waits for the activity on a channel.
-func waitForActivity(sub chan struct{}) tea.Cmd {
-	return func() tea.Msg {
-		return responseMsg(<-sub)
+func listenForActivity(sub chan<- tea.Msg) {
+	for {
+		time.Sleep(time.Millisecond * time.Duration(rand.Int63n(900)+100)) // nolint:gosec
+		sub <- responseMsg{}
 	}
 }
 
 type model struct {
-	sub       chan struct{} // where we'll receive activity notifications
-	responses int           // how many responses we've received
+	responses int // how many responses we've received
 	spinner   spinner.Model
 	quitting  bool
 }
@@ -47,8 +37,7 @@ type model struct {
 func (m model) Init() tea.Cmd {
 	return tea.Batch(
 		m.spinner.Tick,
-		listenForActivity(m.sub), // generate activity
-		waitForActivity(m.sub),   // wait for activity
+		tea.Listen(listenForActivity), // generate activity
 	)
 }
@@ -58,8 +47,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case responseMsg:
-		m.responses++                    // record external activity
-		return m, waitForActivity(m.sub) // wait for next event
+		m.responses++ // record external activity
+		return m, nil
@@ -79,7 +68,6 @@ func (m model) View() string {
 	p := tea.NewProgram(model{
-		sub:     make(chan struct{}),
 		spinner: spinner.New(),
 	})
@myaaaaaaaaa myaaaaaaaaa added the enhancement New feature or request label Sep 11, 2024
@meowgorithm
Copy link
Member

This is an interesting idea (I'd probably call it tea.Subscribe). How would you cancel it when it's no longer needed?

@myaaaaaaaaa
Copy link
Author

myaaaaaaaaa commented Sep 11, 2024

Returning from the function would be sufficient, with the exact mechanism up to the user to decide. Here's the above example adapted to use a done channel:

func listenForActivity(done chan struct{}) func(chan<- tea.Msg) {
	return func(sub chan<- tea.Msg) {
		for {
			time.Sleep(time.Millisecond * time.Duration(rand.Int63n(900)+100)) // nolint:gosec
			sub <- responseMsg{}

			select {
			case <-done:
				return
			default:
			}
		}
	}
}
func (m model) Init() tea.Cmd {
	return tea.Batch(
		m.spinner.Tick,
		tea.Subscribe(listenForActivity(m.done)),
	)
}
func (m model) somewhere() {
	// ...
	close(m.done)
}

In practice, I suspect most real-world usage would involve progress indicators and be vastly easier to follow (which would be the whole point of this feature request):

func downloadFiles(sub chan<- tea.Msg) {
	fileList := []string{ /* ... */ }
	for i, file := range fileList {
		download(file)
		sub <- progressMsg{i * 100 / len(fileList)}
		sub <- statusMsg{"downloading " + file}
	}
	sub <- progressMsg{100}
	sub <- statusMsg{"finished downloading"}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	// switch case { ...
		return m, tea.Subscribe(downloadFiles)
	// ... }
}

Note how being able to send multiple tea.Msgs allows them to be broken up into smaller and more composable parts.

@meowgorithm
Copy link
Member

I think this generally makes sense, however we'd also need a way to cancel subscriptions from the outside as well. For example, in this case, the user might press a key to cancel the download and we'd need to stop the subscription before the download completed. I suppose Context?

For some reference, Elm programs have a dedicated function for subscriptions which is called after update. It works something like this.

subscriptions : model -> Sub msg
subscriptions m =
    if m.someState then
        Sub.batch [ someSub, someOtherSub ]
    else
        Sub.none

There's a lot of magic in cancelling subscriptions, though, and I know it was a tricky thing to get right.

@myaaaaaaaaa
Copy link
Author

I think this generally makes sense, however we'd also need a way to cancel subscriptions from the outside as well. For example, in this case, the user might press a key to cancel the download and we'd need to stop the subscription before the download completed. I suppose Context?

Let me take a stab at what this might look like:

func downloadFiles(ctx context.Context, fileList []string) func(chan<- tea.Msg) {
	return func(sub chan<- tea.Msg) {
		for i, file := range fileList {
			select {
			case <-ctx.Done():
				sub <- statusMsg{"download cancelled"}
				return
			default:
			}
			download(file)
			sub <- progressMsg{i * 100 / len(fileList)}
			sub <- statusMsg{"downloading " + file}
		}
		sub <- progressMsg{100}
		sub <- statusMsg{"finished downloading"}
	}
}

type model struct {
	// ...
	cancels []context.CancelFunc
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch {
	// case ...
	case "d":
		fileList := []string{ /* ... */ }
		ctx, cancel := context.WithCancel(context.Background())
		m.cancels = append(m.cancels, cancel)
		return m, tea.Subscribe(downloadFiles(ctx, fileList))

	case "esc":
		for _, cancel := range m.cancels {
			cancel()
		}
		m.cancels = nil
		return m, nil
	}
}

Does that look right?

If so, then it seems like there's no need for Bubble Tea itself to be aware of context.Context since cancelling is a fully orthogonal feature. Leaving it out would allow the API to remain simple, and enable users to use any cancellation mechanism they see fit1 - including omitting it entirely.

On a side note, it dawned on me while I was typing this that Bubble Tea doesn't expose any concurrency primitives anywhere, so perhaps tea.Subscribe should also follow this convention:

-func downloadFiles(ctx context.Context, fileList []string) func(chan<- tea.Msg) {
-	return func(sub chan<- tea.Msg) {
+func downloadFiles(ctx context.Context, fileList []string) func(func(tea.Msg)) {
+	return func(send func(tea.Msg)) {
 		for i, file := range fileList {
 			select {
 			case <-ctx.Done():
-				sub <- statusMsg{"download cancelled"}
+				send(statusMsg{"download cancelled"})
 				return
 			default:
 			}
 			download(file)
-			sub <- progressMsg{i * 100 / len(fileList)}
-			sub <- statusMsg{"downloading " + file}
+			send(progressMsg{i * 100 / len(fileList)})
+			send(statusMsg{"downloading " + file})
 		}
-		sub <- progressMsg{100}
-		sub <- statusMsg{"finished downloading"}
+		send(progressMsg{100})
+		send(statusMsg{"finished downloading"})
 	}
 }

Footnotes

  1. I'm personally not particularly fond of context.Context. I feel that it's over-engineered and that there's plenty of ways to design a simpler cancellation pattern - for example, passing around a canceled func() error parameter, or writing Go 1.23 iterators that are designed to be cancellable.

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

No branches or pull requests

2 participants