From 2fbf2176cf9721c87c2ff4fd1335a5248daafae8 Mon Sep 17 00:00:00 2001
From: Rob Watson <rob@netflux.io>
Date: Tue, 8 Apr 2025 13:39:16 +0200
Subject: [PATCH] feat(ui): improve key handling

- handle add-destination input in own handler func
- handle CTRL-C when modal is visible
- fix destination wraparound on key-up
---
 internal/terminal/terminal.go | 122 ++++++++++++++++++++--------------
 1 file changed, 72 insertions(+), 50 deletions(-)

diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go
index c20a561..5856c72 100644
--- a/internal/terminal/terminal.go
+++ b/internal/terminal/terminal.go
@@ -40,9 +40,11 @@ const (
 
 // UI is responsible for managing the terminal user interface.
 type UI struct {
-	commandCh chan Command
-	buildInfo domain.BuildInfo
-	logger    *slog.Logger
+	commandCh          chan Command
+	clipboardAvailable bool
+	configFilePath     string
+	buildInfo          domain.BuildInfo
+	logger             *slog.Logger
 
 	// tview state
 
@@ -214,14 +216,16 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
 	app.EnableMouse(false)
 
 	ui := &UI{
-		commandCh:      commandCh,
-		buildInfo:      params.BuildInfo,
-		logger:         params.Logger,
-		app:            app,
-		screen:         screen,
-		screenCaptureC: screenCaptureC,
-		pages:          pages,
-		container:      container,
+		commandCh:          commandCh,
+		clipboardAvailable: params.ClipboardAvailable,
+		configFilePath:     params.ConfigFilePath,
+		buildInfo:          params.BuildInfo,
+		logger:             params.Logger,
+		app:                app,
+		screen:             screen,
+		screenCaptureC:     screenCaptureC,
+		pages:              pages,
+		container:          container,
 		sourceViews: sourceViews{
 			url:    urlTextView,
 			status: statusTextView,
@@ -237,45 +241,7 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
 		urlsToStartState:  make(map[string]startState),
 	}
 
-	app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
-		// Special case: allow all keys except Escape to be passed to the add
-		// destination modal.
-		//
-		// TODO: catch Ctrl-c
-		if pageName, _ := pages.GetFrontPage(); pageName == pageNameAddDestination {
-			if event.Key() == tcell.KeyEscape {
-				ui.closeAddDestinationForm()
-				return nil
-			}
-
-			return event
-		}
-
-		switch event.Key() {
-		case tcell.KeyRune:
-			switch event.Rune() {
-			case 'a', 'A':
-				ui.addDestination()
-				return nil
-			case 'r', 'R':
-				ui.removeDestination()
-				return nil
-			case ' ':
-				ui.toggleDestination()
-			case 'u', 'U':
-				ui.copySourceURLToClipboard(params.ClipboardAvailable)
-			case 'c', 'C':
-				ui.copyConfigFilePathToClipboard(params.ClipboardAvailable, params.ConfigFilePath)
-			case '?':
-				ui.showAbout()
-			}
-		case tcell.KeyCtrlC:
-			ui.confirmQuit()
-			return nil
-		}
-
-		return event
-	})
+	app.SetInputCapture(ui.handleInputCapture)
 
 	if ui.screenCaptureC != nil {
 		app.SetAfterDrawFunc(ui.captureScreen)
@@ -315,6 +281,45 @@ func (ui *UI) run(ctx context.Context) {
 	}
 }
 
+func (ui *UI) handleInputCapture(event *tcell.EventKey) *tcell.EventKey {
+	// Special case: handle CTRL-C even when a modal is visible.
+	if event.Key() == tcell.KeyCtrlC {
+		ui.confirmQuit()
+		return nil
+	}
+
+	if ui.modalVisible() {
+		return event
+	}
+
+	switch event.Key() {
+	case tcell.KeyRune:
+		switch event.Rune() {
+		case 'a', 'A':
+			ui.addDestination()
+			return nil
+		case 'r', 'R':
+			ui.removeDestination()
+			return nil
+		case ' ':
+			ui.toggleDestination()
+		case 'u', 'U':
+			ui.copySourceURLToClipboard(ui.clipboardAvailable)
+		case 'c', 'C':
+			ui.copyConfigFilePathToClipboard(ui.clipboardAvailable, ui.configFilePath)
+		case '?':
+			ui.showAbout()
+		}
+	case tcell.KeyUp:
+		row, _ := ui.destView.GetSelection()
+		if row == 1 {
+			ui.destView.Select(ui.destView.GetRowCount(), 0)
+		}
+	}
+
+	return event
+}
+
 func (ui *UI) ShowSourceNotLiveModal() {
 	ui.app.QueueUpdateDraw(func() {
 		ui.showModal(
@@ -508,6 +513,11 @@ func (ui *UI) resetFocus() {
 	}
 }
 
+func (ui *UI) modalVisible() bool {
+	pageName, _ := ui.pages.GetFrontPage()
+	return pageName != pageNameMain && pageName != pageNameNoDestinations
+}
+
 func (ui *UI) showModal(pageName string, text string, buttons []string, doneFunc func(int, string)) {
 	if ui.pages.HasPage(pageName) {
 		return
@@ -520,6 +530,11 @@ func (ui *UI) showModal(pageName string, text string, buttons []string, doneFunc
 		SetTextColor(tcell.ColorWhite).
 		SetDoneFunc(func(buttonIndex int, buttonLabel string) {
 			ui.pages.RemovePage(pageName)
+
+			if !ui.modalVisible() {
+				ui.app.SetInputCapture(ui.handleInputCapture)
+			}
+
 			ui.resetFocus()
 
 			if doneFunc != nil {
@@ -753,6 +768,13 @@ func (ui *UI) addDestination() {
 		SetBorder(true).
 		SetTitle("Add a new destination").
 		SetTitleAlign(tview.AlignLeft).
+		SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+			if event.Key() == tcell.KeyEscape {
+				ui.closeAddDestinationForm()
+				return nil
+			}
+			return event
+		}).
 		SetRect((currWidth-formWidth)/2, (currHeight-formHeight)/2, formWidth, formHeight)
 
 	ui.mu.Lock()