From b8550f050b4bf04104fc697137a836c11e6f13a4 Mon Sep 17 00:00:00 2001
From: Rob Watson <rob@netflux.io>
Date: Wed, 23 Apr 2025 20:32:31 +0200
Subject: [PATCH] refactor(app): add AppStateChangedEvent

---
 internal/app/app.go           |  22 ++++--
 internal/event/events.go      |  13 ++++
 internal/terminal/terminal.go | 139 +++++++++++++++++-----------------
 3 files changed, 97 insertions(+), 77 deletions(-)

diff --git a/internal/app/app.go b/internal/app/app.go
index 893dd60..b7c2ecc 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -89,7 +89,9 @@ func (a *App) Run(ctx context.Context) error {
 	// It is only needed for integration tests when rendering modals before the
 	// main loop starts. It would be nice to remove this but the risk/impact on
 	// non-test code is pretty low.
-	emptyUI := func() { ui.SetState(domain.AppState{}) }
+	emptyUI := func() {
+		a.eventBus.Send(event.AppStateChangedEvent{State: domain.AppState{}})
+	}
 
 	containerClient, err := container.NewClient(ctx, a.dockerClient, a.logger.With("component", "container_client"))
 	if err != nil {
@@ -109,7 +111,11 @@ func (a *App) Run(ctx context.Context) error {
 	}
 	defer containerClient.Close()
 
-	updateUI := func() { ui.SetState(*state) }
+	updateUI := func() {
+		// The state is mutable so can't be passed into another goroutine
+		// without cloning it first.
+		a.eventBus.Send(event.AppStateChangedEvent{State: state.Clone()})
+	}
 	updateUI()
 
 	var tlsCertPath, tlsKeyPath string
@@ -219,7 +225,7 @@ func (a *App) handleCommand(
 			break
 		}
 		a.cfg = newCfg
-		handleConfigUpdate(a.cfg, state, ui)
+		a.handleConfigUpdate(state)
 		a.eventBus.Send(event.DestinationAddedEvent{URL: c.URL})
 	case domain.CommandRemoveDestination:
 		repl.StopDestination(c.URL) // no-op if not live
@@ -233,7 +239,7 @@ func (a *App) handleCommand(
 			break
 		}
 		a.cfg = newCfg
-		handleConfigUpdate(a.cfg, state, ui)
+		a.handleConfigUpdate(state)
 		a.eventBus.Send(event.DestinationRemovedEvent{URL: c.URL})
 	case domain.CommandStartDestination:
 		if !state.Source.Live {
@@ -251,10 +257,10 @@ func (a *App) handleCommand(
 	return true
 }
 
-// handleConfigUpdate applies the config to the app state, and updates the UI.
-func handleConfigUpdate(cfg config.Config, appState *domain.AppState, ui *terminal.UI) {
-	applyConfig(cfg, appState)
-	ui.SetState(*appState)
+// handleConfigUpdate applies the config to the app state, and sends an AppStateChangedEvent.
+func (a *App) handleConfigUpdate(appState *domain.AppState) {
+	applyConfig(a.cfg, appState)
+	a.eventBus.Send(event.AppStateChangedEvent{State: appState.Clone()})
 }
 
 // applyServerState applies the current server state to the app state.
diff --git a/internal/event/events.go b/internal/event/events.go
index 8bc31bd..8220528 100644
--- a/internal/event/events.go
+++ b/internal/event/events.go
@@ -1,18 +1,31 @@
 package event
 
+import "git.netflux.io/rob/octoplex/internal/domain"
+
 type Name string
 
 const (
+	EventNameAppStateChanged    Name = "app_state_changed"
 	EventNameDestinationAdded   Name = "destination_added"
 	EventNameDestinationRemoved Name = "destination_removed"
 	EventNameMediaServerStarted Name = "media_server_started"
 	EventNameFatalErrorOccurred Name = "fatal_error_occurred"
 )
 
+// Event represents something which happened in the appllication.
 type Event interface {
 	name() Name
 }
 
+// AppStateChangedEvent is emitted when the application state changes.
+type AppStateChangedEvent struct {
+	State domain.AppState
+}
+
+func (e AppStateChangedEvent) name() Name {
+	return EventNameAppStateChanged
+}
+
 // DestinationAddedEvent is emitted when a destination is successfully added.
 type DestinationAddedEvent struct {
 	URL string
diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go
index 9add13a..a6bdbfa 100644
--- a/internal/terminal/terminal.go
+++ b/internal/terminal/terminal.go
@@ -279,6 +279,7 @@ func (ui *UI) C() <-chan domain.Command {
 func (ui *UI) run(ctx context.Context) {
 	defer close(ui.commandC)
 
+	appStateChangedC := ui.eventBus.Register(event.EventNameAppStateChanged)
 	destinationAddedC := ui.eventBus.Register(event.EventNameDestinationAdded)
 	destinationRemovedC := ui.eventBus.Register(event.EventNameDestinationRemoved)
 	mediaServerStartedC := ui.eventBus.Register(event.EventNameMediaServerStarted)
@@ -297,14 +298,26 @@ func (ui *UI) run(ctx context.Context) {
 
 	for {
 		select {
+		case evt := <-appStateChangedC:
+			ui.app.QueueUpdateDraw(func() {
+				ui.handleAppStateChanged(evt.(event.AppStateChangedEvent))
+			})
 		case evt := <-destinationAddedC:
-			ui.handleDestinationAdded(evt.(event.DestinationAddedEvent))
+			ui.app.QueueUpdateDraw(func() {
+				ui.handleDestinationAdded(evt.(event.DestinationAddedEvent))
+			})
 		case evt := <-destinationRemovedC:
-			ui.handleDestinationRemoved(evt.(event.DestinationRemovedEvent))
+			ui.app.QueueUpdateDraw(func() {
+				ui.handleDestinationRemoved(evt.(event.DestinationRemovedEvent))
+			})
 		case evt := <-mediaServerStartedC:
-			ui.handleMediaServerStarted(evt.(event.MediaServerStartedEvent))
+			ui.app.QueueUpdateDraw(func() {
+				ui.handleMediaServerStarted(evt.(event.MediaServerStartedEvent))
+			})
 		case evt := <-fatalErrorOccurredC:
-			ui.handleFatalErrorOccurred(evt.(event.FatalErrorOccurredEvent))
+			ui.app.QueueUpdateDraw(func() {
+				ui.handleFatalErrorOccurred(evt.(event.FatalErrorOccurredEvent))
+			})
 		case <-ctx.Done():
 			return
 		case <-uiDone:
@@ -319,7 +332,7 @@ func (ui *UI) handleMediaServerStarted(evt event.MediaServerStartedEvent) {
 	ui.rtmpsURL = evt.RTMPSURL
 	ui.mu.Unlock()
 
-	ui.app.QueueUpdateDraw(ui.renderAboutView)
+	ui.renderAboutView()
 }
 
 func (ui *UI) inputCaptureHandler(event *tcell.EventKey) *tcell.EventKey {
@@ -447,20 +460,18 @@ func (ui *UI) ShowDestinationErrorModal(name string, err error) {
 }
 
 func (ui *UI) handleFatalErrorOccurred(evt event.FatalErrorOccurredEvent) {
-	ui.app.QueueUpdateDraw(func() {
-		ui.showModal(
-			pageNameModalFatalError,
-			fmt.Sprintf(
-				"An error occurred:\n\n%s",
-				evt.Message,
-			),
-			[]string{"Quit"},
-			false,
-			func(int, string) {
-				ui.commandC <- domain.CommandQuit{}
-			},
-		)
-	})
+	ui.showModal(
+		pageNameModalFatalError,
+		fmt.Sprintf(
+			"An error occurred:\n\n%s",
+			evt.Message,
+		),
+		[]string{"Quit"},
+		false,
+		func(int, string) {
+			ui.commandC <- domain.CommandQuit{}
+		},
+	)
 }
 
 func (ui *UI) afterDrawHandler(screen tcell.Screen) {
@@ -491,8 +502,9 @@ func (ui *UI) captureScreen(screen tcell.Screen) {
 	}
 }
 
-// SetState sets the state of the terminal user interface.
-func (ui *UI) SetState(state domain.AppState) {
+func (ui *UI) handleAppStateChanged(evt event.AppStateChangedEvent) {
+	state := evt.State
+
 	if state.Source.ExitReason != "" {
 		ui.handleMediaServerClosed(state.Source.ExitReason)
 	}
@@ -507,10 +519,7 @@ func (ui *UI) SetState(state domain.AppState) {
 	ui.hasDestinations = len(state.Destinations) > 0
 	ui.mu.Unlock()
 
-	// The state is mutable so can't be passed into QueueUpdateDraw, which
-	// passes it to another goroutine, without cloning it first.
-	stateClone := state.Clone()
-	ui.app.QueueUpdateDraw(func() { ui.redrawFromState(stateClone) })
+	ui.redrawFromState(state)
 }
 
 func (ui *UI) updatePullProgress(state domain.AppState) {
@@ -531,9 +540,7 @@ func (ui *UI) updatePullProgress(state domain.AppState) {
 	}
 
 	if len(pullingContainers) == 0 {
-		ui.app.QueueUpdateDraw(func() {
-			ui.hideModal(pageNameModalPullProgress)
-		})
+		ui.hideModal(pageNameModalPullProgress)
 		return
 	}
 
@@ -547,29 +554,27 @@ func (ui *UI) updatePullProgress(state domain.AppState) {
 }
 
 func (ui *UI) updateProgressModal(container domain.Container) {
-	ui.app.QueueUpdateDraw(func() {
-		modalName := string(pageNameModalPullProgress)
+	modalName := string(pageNameModalPullProgress)
 
-		var status string
-		// Avoid showing the long Docker pull status in the modal content.
-		if len(container.PullStatus) < 30 {
-			status = container.PullStatus
-		}
+	var status string
+	// Avoid showing the long Docker pull status in the modal content.
+	if len(container.PullStatus) < 30 {
+		status = container.PullStatus
+	}
 
-		modalContent := fmt.Sprintf(
-			"Pulling %s:\n%s (%d%%)\n\n%s",
-			container.ImageName,
-			status,
-			container.PullPercent,
-			container.PullProgress,
-		)
+	modalContent := fmt.Sprintf(
+		"Pulling %s:\n%s (%d%%)\n\n%s",
+		container.ImageName,
+		status,
+		container.PullPercent,
+		container.PullProgress,
+	)
 
-		if ui.pages.HasPage(modalName) {
-			ui.pullProgressModal.SetText(modalContent)
-		} else {
-			ui.pages.AddPage(modalName, ui.pullProgressModal, true, true)
-		}
-	})
+	if ui.pages.HasPage(modalName) {
+		ui.pullProgressModal.SetText(modalContent)
+	} else {
+		ui.pages.AddPage(modalName, ui.pullProgressModal, true, true)
+	}
 }
 
 // page names represent a specific page in the terminal user interface.
@@ -699,23 +704,21 @@ func (ui *UI) hideModal(pageName string) {
 }
 
 func (ui *UI) handleMediaServerClosed(exitReason string) {
-	ui.app.QueueUpdateDraw(func() {
-		if ui.pages.HasPage(pageNameModalSourceError) {
-			return
-		}
+	if ui.pages.HasPage(pageNameModalSourceError) {
+		return
+	}
 
-		modal := tview.NewModal()
-		modal.SetText("Mediaserver error: " + exitReason).
-			AddButtons([]string{"Quit"}).
-			SetBackgroundColor(tcell.ColorBlack).
-			SetTextColor(tcell.ColorWhite).
-			SetDoneFunc(func(int, string) {
-				ui.commandC <- domain.CommandQuit{}
-			})
-		modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
+	modal := tview.NewModal()
+	modal.SetText("Mediaserver error: " + exitReason).
+		AddButtons([]string{"Quit"}).
+		SetBackgroundColor(tcell.ColorBlack).
+		SetTextColor(tcell.ColorWhite).
+		SetDoneFunc(func(int, string) {
+			ui.commandC <- domain.CommandQuit{}
+		})
+	modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
 
-		ui.pages.AddPage(pageNameModalSourceError, modal, true, true)
-	})
+	ui.pages.AddPage(pageNameModalSourceError, modal, true, true)
 }
 
 const dash = "—"
@@ -969,15 +972,13 @@ func (ui *UI) handleDestinationAdded(event.DestinationAddedEvent) {
 	ui.hasDestinations = true
 	ui.mu.Unlock()
 
-	ui.app.QueueUpdateDraw(func() {
-		ui.pages.HidePage(pageNameNoDestinations)
-		ui.closeAddDestinationForm()
-		ui.selectLastDestination()
-	})
+	ui.pages.HidePage(pageNameNoDestinations)
+	ui.closeAddDestinationForm()
+	ui.selectLastDestination()
 }
 
 func (ui *UI) handleDestinationRemoved(event.DestinationRemovedEvent) {
-	ui.app.QueueUpdateDraw(ui.selectPreviousDestination)
+	ui.selectPreviousDestination()
 }
 
 func (ui *UI) closeAddDestinationForm() {