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 · 5 comments
Open

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

myaaaaaaaaa opened this issue Sep 11, 2024 · 5 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.

@bjornbyte
Copy link

I do like that last one (using send function instead of a channel). I found this issue while trying to work out how to do a websocket connection and have it inject messages that come over the websocket as messages. Intuitively what I would want would be like:

  1. from model.Update() return a "connectWebsocket" command
  2. connect websocket runs, produces a "websocket connected" (or "connection error") message, and then continues to produce messages as they arrive, and eventually a "websocket disconnected" messsage.

then I'd implement reconnect by just sending another "connectWebsocket" command.

But I'm going to want to send on the websocket also, which means I'll need to keep some sort of object on my model representing the websocket connection, and so maybe the "listenForActivity" pattern used in the real time example is actually simplest for this case; I'm just not sure I want to care for the recieved messages that they came via the websocket and need to issue another listenForActivity command.

Anyway, I wanted to mention it here as a use case to help inform the solution.

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

3 participants