refactor(app): add AppStateChangedEvent

This commit is contained in:
Rob Watson 2025-04-23 20:32:31 +02:00
parent c02a66202f
commit b8550f050b
3 changed files with 97 additions and 77 deletions

View File

@ -89,7 +89,9 @@ func (a *App) Run(ctx context.Context) error {
// It is only needed for integration tests when rendering modals before the // 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 // main loop starts. It would be nice to remove this but the risk/impact on
// non-test code is pretty low. // 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")) containerClient, err := container.NewClient(ctx, a.dockerClient, a.logger.With("component", "container_client"))
if err != nil { if err != nil {
@ -109,7 +111,11 @@ func (a *App) Run(ctx context.Context) error {
} }
defer containerClient.Close() 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() updateUI()
var tlsCertPath, tlsKeyPath string var tlsCertPath, tlsKeyPath string
@ -219,7 +225,7 @@ func (a *App) handleCommand(
break break
} }
a.cfg = newCfg a.cfg = newCfg
handleConfigUpdate(a.cfg, state, ui) a.handleConfigUpdate(state)
a.eventBus.Send(event.DestinationAddedEvent{URL: c.URL}) a.eventBus.Send(event.DestinationAddedEvent{URL: c.URL})
case domain.CommandRemoveDestination: case domain.CommandRemoveDestination:
repl.StopDestination(c.URL) // no-op if not live repl.StopDestination(c.URL) // no-op if not live
@ -233,7 +239,7 @@ func (a *App) handleCommand(
break break
} }
a.cfg = newCfg a.cfg = newCfg
handleConfigUpdate(a.cfg, state, ui) a.handleConfigUpdate(state)
a.eventBus.Send(event.DestinationRemovedEvent{URL: c.URL}) a.eventBus.Send(event.DestinationRemovedEvent{URL: c.URL})
case domain.CommandStartDestination: case domain.CommandStartDestination:
if !state.Source.Live { if !state.Source.Live {
@ -251,10 +257,10 @@ func (a *App) handleCommand(
return true return true
} }
// handleConfigUpdate applies the config to the app state, and updates the UI. // handleConfigUpdate applies the config to the app state, and sends an AppStateChangedEvent.
func handleConfigUpdate(cfg config.Config, appState *domain.AppState, ui *terminal.UI) { func (a *App) handleConfigUpdate(appState *domain.AppState) {
applyConfig(cfg, appState) applyConfig(a.cfg, appState)
ui.SetState(*appState) a.eventBus.Send(event.AppStateChangedEvent{State: appState.Clone()})
} }
// applyServerState applies the current server state to the app state. // applyServerState applies the current server state to the app state.

View File

@ -1,18 +1,31 @@
package event package event
import "git.netflux.io/rob/octoplex/internal/domain"
type Name string type Name string
const ( const (
EventNameAppStateChanged Name = "app_state_changed"
EventNameDestinationAdded Name = "destination_added" EventNameDestinationAdded Name = "destination_added"
EventNameDestinationRemoved Name = "destination_removed" EventNameDestinationRemoved Name = "destination_removed"
EventNameMediaServerStarted Name = "media_server_started" EventNameMediaServerStarted Name = "media_server_started"
EventNameFatalErrorOccurred Name = "fatal_error_occurred" EventNameFatalErrorOccurred Name = "fatal_error_occurred"
) )
// Event represents something which happened in the appllication.
type Event interface { type Event interface {
name() Name 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. // DestinationAddedEvent is emitted when a destination is successfully added.
type DestinationAddedEvent struct { type DestinationAddedEvent struct {
URL string URL string

View File

@ -279,6 +279,7 @@ func (ui *UI) C() <-chan domain.Command {
func (ui *UI) run(ctx context.Context) { func (ui *UI) run(ctx context.Context) {
defer close(ui.commandC) defer close(ui.commandC)
appStateChangedC := ui.eventBus.Register(event.EventNameAppStateChanged)
destinationAddedC := ui.eventBus.Register(event.EventNameDestinationAdded) destinationAddedC := ui.eventBus.Register(event.EventNameDestinationAdded)
destinationRemovedC := ui.eventBus.Register(event.EventNameDestinationRemoved) destinationRemovedC := ui.eventBus.Register(event.EventNameDestinationRemoved)
mediaServerStartedC := ui.eventBus.Register(event.EventNameMediaServerStarted) mediaServerStartedC := ui.eventBus.Register(event.EventNameMediaServerStarted)
@ -297,14 +298,26 @@ func (ui *UI) run(ctx context.Context) {
for { for {
select { select {
case evt := <-appStateChangedC:
ui.app.QueueUpdateDraw(func() {
ui.handleAppStateChanged(evt.(event.AppStateChangedEvent))
})
case evt := <-destinationAddedC: case evt := <-destinationAddedC:
ui.app.QueueUpdateDraw(func() {
ui.handleDestinationAdded(evt.(event.DestinationAddedEvent)) ui.handleDestinationAdded(evt.(event.DestinationAddedEvent))
})
case evt := <-destinationRemovedC: case evt := <-destinationRemovedC:
ui.app.QueueUpdateDraw(func() {
ui.handleDestinationRemoved(evt.(event.DestinationRemovedEvent)) ui.handleDestinationRemoved(evt.(event.DestinationRemovedEvent))
})
case evt := <-mediaServerStartedC: case evt := <-mediaServerStartedC:
ui.app.QueueUpdateDraw(func() {
ui.handleMediaServerStarted(evt.(event.MediaServerStartedEvent)) ui.handleMediaServerStarted(evt.(event.MediaServerStartedEvent))
})
case evt := <-fatalErrorOccurredC: case evt := <-fatalErrorOccurredC:
ui.app.QueueUpdateDraw(func() {
ui.handleFatalErrorOccurred(evt.(event.FatalErrorOccurredEvent)) ui.handleFatalErrorOccurred(evt.(event.FatalErrorOccurredEvent))
})
case <-ctx.Done(): case <-ctx.Done():
return return
case <-uiDone: case <-uiDone:
@ -319,7 +332,7 @@ func (ui *UI) handleMediaServerStarted(evt event.MediaServerStartedEvent) {
ui.rtmpsURL = evt.RTMPSURL ui.rtmpsURL = evt.RTMPSURL
ui.mu.Unlock() ui.mu.Unlock()
ui.app.QueueUpdateDraw(ui.renderAboutView) ui.renderAboutView()
} }
func (ui *UI) inputCaptureHandler(event *tcell.EventKey) *tcell.EventKey { func (ui *UI) inputCaptureHandler(event *tcell.EventKey) *tcell.EventKey {
@ -447,7 +460,6 @@ func (ui *UI) ShowDestinationErrorModal(name string, err error) {
} }
func (ui *UI) handleFatalErrorOccurred(evt event.FatalErrorOccurredEvent) { func (ui *UI) handleFatalErrorOccurred(evt event.FatalErrorOccurredEvent) {
ui.app.QueueUpdateDraw(func() {
ui.showModal( ui.showModal(
pageNameModalFatalError, pageNameModalFatalError,
fmt.Sprintf( fmt.Sprintf(
@ -460,7 +472,6 @@ func (ui *UI) handleFatalErrorOccurred(evt event.FatalErrorOccurredEvent) {
ui.commandC <- domain.CommandQuit{} ui.commandC <- domain.CommandQuit{}
}, },
) )
})
} }
func (ui *UI) afterDrawHandler(screen tcell.Screen) { 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) handleAppStateChanged(evt event.AppStateChangedEvent) {
func (ui *UI) SetState(state domain.AppState) { state := evt.State
if state.Source.ExitReason != "" { if state.Source.ExitReason != "" {
ui.handleMediaServerClosed(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.hasDestinations = len(state.Destinations) > 0
ui.mu.Unlock() ui.mu.Unlock()
// The state is mutable so can't be passed into QueueUpdateDraw, which ui.redrawFromState(state)
// passes it to another goroutine, without cloning it first.
stateClone := state.Clone()
ui.app.QueueUpdateDraw(func() { ui.redrawFromState(stateClone) })
} }
func (ui *UI) updatePullProgress(state domain.AppState) { func (ui *UI) updatePullProgress(state domain.AppState) {
@ -531,9 +540,7 @@ func (ui *UI) updatePullProgress(state domain.AppState) {
} }
if len(pullingContainers) == 0 { if len(pullingContainers) == 0 {
ui.app.QueueUpdateDraw(func() {
ui.hideModal(pageNameModalPullProgress) ui.hideModal(pageNameModalPullProgress)
})
return return
} }
@ -547,7 +554,6 @@ func (ui *UI) updatePullProgress(state domain.AppState) {
} }
func (ui *UI) updateProgressModal(container domain.Container) { func (ui *UI) updateProgressModal(container domain.Container) {
ui.app.QueueUpdateDraw(func() {
modalName := string(pageNameModalPullProgress) modalName := string(pageNameModalPullProgress)
var status string var status string
@ -569,7 +575,6 @@ func (ui *UI) updateProgressModal(container domain.Container) {
} else { } else {
ui.pages.AddPage(modalName, ui.pullProgressModal, true, true) ui.pages.AddPage(modalName, ui.pullProgressModal, true, true)
} }
})
} }
// page names represent a specific page in the terminal user interface. // page names represent a specific page in the terminal user interface.
@ -699,7 +704,6 @@ func (ui *UI) hideModal(pageName string) {
} }
func (ui *UI) handleMediaServerClosed(exitReason string) { func (ui *UI) handleMediaServerClosed(exitReason string) {
ui.app.QueueUpdateDraw(func() {
if ui.pages.HasPage(pageNameModalSourceError) { if ui.pages.HasPage(pageNameModalSourceError) {
return return
} }
@ -715,7 +719,6 @@ func (ui *UI) handleMediaServerClosed(exitReason string) {
modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite)) 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 = "—" const dash = "—"
@ -969,15 +972,13 @@ func (ui *UI) handleDestinationAdded(event.DestinationAddedEvent) {
ui.hasDestinations = true ui.hasDestinations = true
ui.mu.Unlock() ui.mu.Unlock()
ui.app.QueueUpdateDraw(func() {
ui.pages.HidePage(pageNameNoDestinations) ui.pages.HidePage(pageNameNoDestinations)
ui.closeAddDestinationForm() ui.closeAddDestinationForm()
ui.selectLastDestination() ui.selectLastDestination()
})
} }
func (ui *UI) handleDestinationRemoved(event.DestinationRemovedEvent) { func (ui *UI) handleDestinationRemoved(event.DestinationRemovedEvent) {
ui.app.QueueUpdateDraw(ui.selectPreviousDestination) ui.selectPreviousDestination()
} }
func (ui *UI) closeAddDestinationForm() { func (ui *UI) closeAddDestinationForm() {