fix(ui): further key handling improvements

Avoids losing user destination selection when re-rendering the page,
especially after adding or removing a destination.
This commit is contained in:
Rob Watson 2025-04-10 07:04:26 +02:00
parent f791125c02
commit d332a78af1
3 changed files with 99 additions and 29 deletions

View File

@ -120,9 +120,8 @@ func Run(ctx context.Context, params RunParams) error {
return fmt.Errorf("start mediaserver: %w", err)
}
}
case cfg = <-params.ConfigService.C():
applyConfig(cfg, state)
updateUI()
case <-params.ConfigService.C():
// No-op, config updates are handled synchronously for now.
case cmd, ok := <-ui.C():
if !ok {
// TODO: keep UI open until all containers have closed
@ -143,6 +142,8 @@ func Run(ctx context.Context, params RunParams) error {
ui.ConfigUpdateFailed(err)
continue
}
cfg = newCfg
handleConfigUpdate(cfg, state, ui)
ui.DestinationAdded()
case terminal.CommandRemoveDestination:
repl.StopDestination(c.URL) // no-op if not live
@ -155,6 +156,9 @@ func Run(ctx context.Context, params RunParams) error {
ui.ConfigUpdateFailed(err)
continue
}
cfg = newCfg
handleConfigUpdate(cfg, state, ui)
ui.DestinationRemoved()
case terminal.CommandStartDestination:
if !state.Source.Live {
ui.ShowSourceNotLiveModal()
@ -186,6 +190,12 @@ func Run(ctx context.Context, params RunParams) error {
}
}
// 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)
}
// applyServerState applies the current server state to the app state.
func applyServerState(serverState domain.Source, appState *domain.AppState) {
appState.Source = serverState

View File

@ -189,6 +189,7 @@ func TestIntegration(t *testing.T) {
)
printScreen(getContents, "After starting the destination streams")
sendKey(screen, tcell.KeyUp, ' ')
sendKey(screen, tcell.KeyDelete, ' ')
sendKey(screen, tcell.KeyEnter, ' ')

View File

@ -68,6 +68,9 @@ type UI struct {
// hasDestinations is true if the UI thinks there are destinations
// configured.
hasDestinations bool
// lastSelectedDestIndex is the index of the last selected destination, starting
// at 1 (because 0 is the header).
lastSelectedDestIndex int
}
// Screen represents a terminal screen. This includes its desired dimensions,
@ -242,11 +245,8 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
urlsToStartState: make(map[string]startState),
}
app.SetInputCapture(ui.handleInputCapture)
if ui.screenCaptureC != nil {
app.SetAfterDrawFunc(ui.captureScreen)
}
app.SetInputCapture(ui.inputCaptureHandler)
app.SetAfterDrawFunc(ui.afterDrawHandler)
go ui.run(ctx)
@ -282,7 +282,7 @@ func (ui *UI) run(ctx context.Context) {
}
}
func (ui *UI) handleInputCapture(event *tcell.EventKey) *tcell.EventKey {
func (ui *UI) inputCaptureHandler(event *tcell.EventKey) *tcell.EventKey {
// Special case: handle CTRL-C even when a modal is visible.
if event.Key() == tcell.KeyCtrlC {
ui.confirmQuit()
@ -398,6 +398,14 @@ func (ui *UI) ShowFatalErrorModal(err error) {
})
}
func (ui *UI) afterDrawHandler(screen tcell.Screen) {
if ui.screenCaptureC == nil {
return
}
ui.captureScreen(screen)
}
// captureScreen captures the screen and sends it to the screenCaptureC
// channel, which must have been set in StartParams.
//
@ -519,26 +527,63 @@ const (
pageNameModalStartupCheck = "modal-startup-check"
)
func (ui *UI) resetFocus() {
if name, el := ui.pages.GetFrontPage(); name == pageNameMain || name == pageNameNoDestinations {
ui.app.SetFocus(ui.destView)
// If we don't explicitly set the focus to some row, then sometimes no row
// is selected and the user must press up or down to select a row before
// continuing. This isn't completely a blocker but it is sometime a bit
// confusing and also makes integration tests less predictable. It would be
// nice to improve this behaviour so that e.g. if a new destination is
// added then that destination is selected, not the first in the row.
ui.destView.Select(1, 0)
} else {
ui.app.SetFocus(el)
}
}
// modalVisible returns true if any modal, including the add destination form,
// is visible.
func (ui *UI) modalVisible() bool {
pageName, _ := ui.pages.GetFrontPage()
return pageName != pageNameMain && pageName != pageNameNoDestinations
}
// saveSelectedDestination saves the last selected destination index to local
// mutable state.
//
// This is needed so that the user's selection can be restored
// after redrawing the screen. It may be possible to remove this if we can
// re-render the screen more selectively instead of calling [redrawFromState]
// every time the state changes.
func (ui *UI) saveSelectedDestination() {
row, _ := ui.destView.GetSelection()
ui.mu.Lock()
ui.lastSelectedDestIndex = row
ui.mu.Unlock()
}
// selectPreviousDestination sets the focus to the last-selected destination.
func (ui *UI) selectPreviousDestination() {
if ui.modalVisible() {
return
}
var row int
ui.mu.Lock()
row = ui.lastSelectedDestIndex
ui.mu.Unlock()
// If the last element has been removed, select the new last element.
row = min(ui.destView.GetRowCount()-1, row)
ui.app.SetFocus(ui.destView)
if row == 0 {
return
}
ui.destView.Select(row, 0)
}
// selectLastDestination sets the user selection to the last destination.
func (ui *UI) selectLastDestination() {
if ui.modalVisible() {
return
}
ui.app.SetFocus(ui.destView)
if rowCount := ui.destView.GetRowCount(); rowCount > 1 {
ui.destView.Select(rowCount-1, 0)
}
}
func (ui *UI) showModal(pageName string, text string, buttons []string, doneFunc func(int, string)) {
if ui.pages.HasPage(pageName) {
return
@ -553,17 +598,19 @@ func (ui *UI) showModal(pageName string, text string, buttons []string, doneFunc
ui.pages.RemovePage(pageName)
if !ui.modalVisible() {
ui.app.SetInputCapture(ui.handleInputCapture)
ui.app.SetInputCapture(ui.inputCaptureHandler)
}
ui.resetFocus()
if doneFunc != nil {
doneFunc(buttonIndex, buttonLabel)
}
ui.selectPreviousDestination()
}).
SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
ui.saveSelectedDestination()
ui.pages.AddPage(pageName, modal, true, true)
}
@ -784,7 +831,10 @@ func (ui *UI) addDestination() {
URL: form.GetFormItemByLabel(inputLabelURL).(*tview.InputField).GetText(),
}
}).
AddButton("Cancel", func() { ui.closeAddDestinationForm() }).
AddButton("Cancel", func() {
ui.closeAddDestinationForm()
ui.selectPreviousDestination()
}).
SetFieldBackgroundColor(tcell.ColorDarkSlateGrey).
SetBorder(true).
SetTitle("Add a new destination").
@ -792,6 +842,7 @@ func (ui *UI) addDestination() {
SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
ui.closeAddDestinationForm()
ui.selectPreviousDestination()
return nil
}
return event
@ -802,6 +853,8 @@ func (ui *UI) addDestination() {
ui.addingDestination = true
ui.mu.Unlock()
ui.saveSelectedDestination()
ui.pages.HidePage(pageNameNoDestinations)
ui.pages.AddPage(pageNameAddDestination, form, false, true)
}
@ -836,6 +889,7 @@ func (ui *UI) removeDestination() {
)
}
// DestinationAdded should be called when a new destination is added.
func (ui *UI) DestinationAdded() {
ui.mu.Lock()
ui.hasDestinations = true
@ -844,9 +898,15 @@ func (ui *UI) DestinationAdded() {
ui.app.QueueUpdateDraw(func() {
ui.pages.HidePage(pageNameNoDestinations)
ui.closeAddDestinationForm()
ui.selectLastDestination()
})
}
// DestinationRemoved should be called when a destination is removed.
func (ui *UI) DestinationRemoved() {
ui.selectPreviousDestination()
}
func (ui *UI) closeAddDestinationForm() {
var hasDestinations bool
ui.mu.Lock()
@ -858,7 +918,6 @@ func (ui *UI) closeAddDestinationForm() {
if !hasDestinations {
ui.pages.ShowPage(pageNameNoDestinations)
}
ui.resetFocus()
}
func (ui *UI) toggleDestination() {