diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index 897e053..124c06b 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -37,13 +37,39 @@ const ( ForwardModeForwardSSH = "Forward + SSH" ) +type connectionConfirmationAction int + +const ( + connectionNoAction connectionConfirmationAction = iota + connectionConfirm + connectionEdit + connectionCancel +) + +func connectionActionForKey(event *tcell.EventKey) connectionConfirmationAction { + switch event.Key() { + case tcell.KeyEnter: + return connectionConfirm + case tcell.KeyEscape: + return connectionCancel + } + if commandKey(event) == 'e' { + return connectionEdit + } + return connectionNoAction +} + +func connectionConfirmationMessage(alias string) string { + return fmt.Sprintf("You are about to connect to %q.\n\nTo edit this item instead, press E.", alias) +} + func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey { // Don't handle global keys when search has focus if t.app.GetFocus() == t.searchBar { return event } - switch event.Rune() { + switch commandKey(event) { case 'q': t.handleQuit() return nil @@ -95,13 +121,64 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey { } if event.Key() == tcell.KeyEnter { - t.handleServerConnect() + if server, ok := t.serverList.GetSelectedServer(); ok { + t.showConnectionConfirmModal(server) + } return nil } return event } +// commandKey normalizes runes only in command contexts. Text inputs, search, +// and dropdown filtering intentionally consume the original event unchanged. +func commandKey(event *tcell.EventKey) rune { + return normalizeGlobalHotkey(event.Rune()) +} + +// normalizeGlobalHotkey preserves command semantics for ASCII keys while +// keeping all other runes untouched. Terminal applications receive the +// resulting rune for printable keys, not a portable physical key code, so +// command handling must not guess a user's keyboard layout. +func normalizeGlobalHotkey(key rune) rune { + switch key { + case 'q', 'Q': + return 'q' + case '/': + return '/' + case 'a', 'A': + return 'a' + case 'e', 'E': + return 'e' + case 'd', 'D': + return 'd' + case 'p', 'P': + return 'p' + case 's': + return 's' + case 'S': + return 'S' + case 'c', 'C': + return 'c' + case 'g', 'G': + return 'g' + case 'r', 'R': + return 'r' + case 't', 'T': + return 't' + case 'f', 'F': + return 'f' + case 'x', 'X': + return 'x' + case 'j', 'J': + return 'j' + case 'k', 'K': + return 'k' + default: + return key + } +} + func (t *tui) handleQuit() { t.app.Stop() } @@ -220,14 +297,11 @@ func (t *tui) handleReturnToSearch() { } } -func (t *tui) handleServerConnect() { - if server, ok := t.serverList.GetSelectedServer(); ok { - - t.app.Suspend(func() { - _ = t.serverService.SSH(server.Alias) - }) - t.refreshServerList() - } +func (t *tui) handleServerConnect(server domain.Server) { + t.app.Suspend(func() { + _ = t.serverService.SSH(server.Alias) + }) + t.refreshServerList() } func (t *tui) handleServerSelectionChange(server domain.Server) { @@ -245,15 +319,19 @@ func (t *tui) handleServerAdd() { func (t *tui) handleServerEdit() { if server, ok := t.serverList.GetSelectedServer(); ok { - form := NewServerForm(ServerFormEdit, &server). - SetApp(t.app). - SetVersionInfo(t.version, t.commit). - OnSave(t.handleServerSave). - OnCancel(t.handleFormCancel) - t.app.SetRoot(form, true) + t.showServerEditForm(server) } } +func (t *tui) showServerEditForm(server domain.Server) { + form := NewServerForm(ServerFormEdit, &server). + SetApp(t.app). + SetVersionInfo(t.version, t.commit). + OnSave(t.handleServerSave). + OnCancel(t.handleFormCancel) + t.app.SetRoot(form, true) +} + func (t *tui) handleServerSave(server domain.Server, original *domain.Server) { var err error if original != nil { @@ -351,6 +429,64 @@ func (t *tui) handleRefreshBackground() { // UI Display Functions (show UI elements/modals) // ============================================================================= +func (t *tui) showConnectionConfirmModal(server domain.Server) { + modal, pages := t.newConnectionConfirmationOverlay(server) + t.app.SetRoot(pages, true) + t.app.SetFocus(modal) +} + +func (t *tui) newConnectionConfirmationOverlay(server domain.Server) (*tview.Modal, *tview.Pages) { + modal := tview.NewModal(). + SetText(tview.Escape(connectionConfirmationMessage(server.Alias))). + AddButtons([]string{"Connect"}). + SetBackgroundColor(tcell.Color235). + SetTextColor(tcell.Color252). + SetButtonStyle(tcell.StyleDefault.Foreground(tcell.Color252).Background(tcell.Color232)). + SetButtonActivatedStyle(tcell.StyleDefault.Foreground(tcell.Color232).Background(tcell.Color252)). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + if buttonIndex == 0 { + t.returnToMain() + t.handleServerConnect(server) + return + } + t.returnToMain() + t.app.SetFocus(t.serverList) + }) + modal.SetBorderColor(tcell.Color238) + modal.SetTitle(" Confirm Connection ") + modal.SetTitleAlign(tview.AlignCenter) + modal.SetTitleColor(tcell.Color250) + + modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch connectionActionForKey(event) { + case connectionConfirm: + return event + case connectionEdit: + t.showServerEditForm(server) + return nil + case connectionCancel: + t.returnToMain() + t.app.SetFocus(t.serverList) + return nil + default: + return event + } + }) + + modal.SetFocus(0) + + pages := tview.NewPages(). + AddPage("main", t.root, true, true). + AddPage("connection-confirmation", modal, true, true) + pages.SetMouseCapture(func(action tview.MouseAction, event *tcell.EventMouse) (tview.MouseAction, *tcell.EventMouse) { + if modal.InRect(event.Position()) { + return action, event + } + return tview.MouseConsumed, nil + }) + return modal, pages +} + func (t *tui) showDeleteConfirmModal(server domain.Server) { msg := fmt.Sprintf("Delete server %s (%s@%s:%d)?\n\nThis action cannot be undone.", server.Alias, server.User, server.Host, server.Port) @@ -368,12 +504,12 @@ func (t *tui) showDeleteConfirmModal(server domain.Server) { // Add keyboard shortcuts for the modal modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Rune() { - case 'c', 'C': + switch commandKey(event) { + case 'c': // Cancel t.handleModalClose() return nil - case 'd', 'D': + case 'd': // Delete _ = t.serverService.DeleteServer(server) t.refreshServerList() diff --git a/internal/adapters/ui/handlers_test.go b/internal/adapters/ui/handlers_test.go new file mode 100644 index 0000000..c9dc028 --- /dev/null +++ b/internal/adapters/ui/handlers_test.go @@ -0,0 +1,275 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/Adembc/lazyssh/internal/core/domain" + "github.com/Adembc/lazyssh/internal/core/ports" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +type recordingServerService struct { + ports.ServerService + servers []domain.Server + sshCalls int + sshAliases []string +} + +func (service *recordingServerService) ListServers(string) ([]domain.Server, error) { + return service.servers, nil +} + +func (service *recordingServerService) SSH(alias string) error { + service.sshCalls++ + service.sshAliases = append(service.sshAliases, alias) + return nil +} + +type connectionConfirmationHarness struct { + app *tview.Application + screen tcell.SimulationScreen + view *tui + service *recordingServerService + server domain.Server + serverList *ServerList +} + +func newConnectionConfirmationHarness(t *testing.T) *connectionConfirmationHarness { + t.Helper() + + server := domain.Server{Alias: "example"} + service := &recordingServerService{servers: []domain.Server{server}} + app := tview.NewApplication() + screen := tcell.NewSimulationScreen("UTF-8") + if err := screen.Init(); err != nil { + t.Fatalf("initialize simulation screen: %v", err) + } + t.Cleanup(screen.Fini) + app.SetScreen(screen) + + serverList := NewServerList() + serverList.UpdateServers(service.servers) + root := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(tview.NewTextView().SetText("MAIN SERVER LIST"), 1, 0, false). + AddItem(serverList, 0, 1, true) + view := &tui{ + app: app, + serverService: service, + serverList: serverList, + searchBar: NewSearchBar(), + root: root, + } + app.SetRoot(root, true) + app.SetFocus(serverList) + + return &connectionConfirmationHarness{ + app: app, + screen: screen, + view: view, + service: service, + server: server, + serverList: serverList, + } +} + +func openConnectionConfirmationOverlay(harness *connectionConfirmationHarness) (*tview.Modal, *tview.Pages) { + modal, pages := harness.view.newConnectionConfirmationOverlay(harness.server) + harness.app.SetRoot(pages, true) + harness.app.SetFocus(modal) + return modal, pages +} + +func sendOverlayKey(app *tview.Application, pages *tview.Pages, event *tcell.EventKey) { + pages.InputHandler()(event, func(primitive tview.Primitive) { + app.SetFocus(primitive) + }) +} + +func TestNormalizeGlobalHotkey(t *testing.T) { + tests := map[rune]rune{ + 'e': 'e', + 'E': 'e', + 's': 's', + 'S': 'S', + '!': '!', + '.': '.', + '1': '1', + } + + for input, expected := range tests { + if actual := normalizeGlobalHotkey(input); actual != expected { + t.Fatalf("normalizeGlobalHotkey(%q) = %q, want %q", input, actual, expected) + } + } +} + +func TestCommandKeyPreservesNonLatinRunes(t *testing.T) { + tests := []struct { + name string + input rune + expected rune + }{ + {name: "latin lower", input: 'd', expected: 'd'}, + {name: "latin caps", input: 'D', expected: 'd'}, + {name: "non-latin rune", input: '\u03bb', expected: '\u03bb'}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + event := tcell.NewEventKey(tcell.KeyRune, test.input, tcell.ModNone) + if actual := commandKey(event); actual != test.expected { + t.Fatalf("commandKey(%q) = %q, want %q", test.input, actual, test.expected) + } + }) + } +} + +func TestConnectionConfirmationAction(t *testing.T) { + tests := []struct { + name string + event *tcell.EventKey + want connectionConfirmationAction + }{ + {name: "enter connects", event: tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone), want: connectionConfirm}, + {name: "escape cancels", event: tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone), want: connectionCancel}, + {name: "english e edits", event: tcell.NewEventKey(tcell.KeyRune, 'E', tcell.ModNone), want: connectionEdit}, + {name: "other key does nothing", event: tcell.NewEventKey(tcell.KeyRune, 'z', tcell.ModNone), want: connectionNoAction}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := connectionActionForKey(test.event); got != test.want { + t.Fatalf("connectionActionForKey() = %v, want %v", got, test.want) + } + }) + } +} + +func TestFirstEnterDoesNotConnect(t *testing.T) { + harness := newConnectionConfirmationHarness(t) + harness.view.handleGlobalKeys(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) + + if harness.service.sshCalls != 0 { + t.Fatalf("first Enter called SSH %d time(s), want 0", harness.service.sshCalls) + } + if harness.app.GetFocus() == harness.serverList { + t.Fatal("first Enter kept focus on server list, want connection confirmation modal button") + } + + harness.app.ForceDraw() + screenText := simulationScreenText(harness.screen) + if !strings.Contains(screenText, "Confirm Connection") { + t.Fatal("first Enter did not render the connection confirmation modal") + } + if !strings.Contains(screenText, "MAIN SERVER LIST") { + t.Fatal("connection confirmation replaced the main UI instead of overlaying it") + } +} + +func TestConnectionConfirmationSecondEnterConnectsOnce(t *testing.T) { + harness := newConnectionConfirmationHarness(t) + _, pages := openConnectionConfirmationOverlay(harness) + harness.service.servers = append(harness.service.servers, domain.Server{Alias: "other"}) + harness.serverList.UpdateServers(harness.service.servers) + harness.serverList.SetCurrentItem(1) + + sendOverlayKey(harness.app, pages, tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) + + if harness.service.sshCalls != 1 { + t.Fatalf("second Enter called SSH %d time(s), want 1", harness.service.sshCalls) + } + if got := harness.service.sshAliases[0]; got != harness.server.Alias { + t.Fatalf("second Enter connected to %q, want displayed alias %q", got, harness.server.Alias) + } +} + +func TestConnectionConfirmationEditKeyOpensEditForm(t *testing.T) { + harness := newConnectionConfirmationHarness(t) + _, pages := openConnectionConfirmationOverlay(harness) + harness.service.servers = append(harness.service.servers, domain.Server{Alias: "other"}) + harness.serverList.UpdateServers(harness.service.servers) + harness.serverList.SetCurrentItem(1) + + sendOverlayKey(harness.app, pages, tcell.NewEventKey(tcell.KeyRune, 'E', tcell.ModNone)) + harness.app.ForceDraw() + + if harness.service.sshCalls != 0 { + t.Fatalf("edit key called SSH %d time(s), want 0", harness.service.sshCalls) + } + screenText := simulationScreenText(harness.screen) + if !strings.Contains(screenText, "Edit Server") { + t.Fatal("E did not open the edit form") + } + if !strings.Contains(screenText, harness.server.Alias) { + t.Fatalf("edit form did not retain displayed alias %q after selection changed", harness.server.Alias) + } +} + +func TestConnectionConfirmationEscapeReturnsToServerList(t *testing.T) { + harness := newConnectionConfirmationHarness(t) + _, pages := openConnectionConfirmationOverlay(harness) + + sendOverlayKey(harness.app, pages, tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone)) + harness.app.ForceDraw() + + if harness.service.sshCalls != 0 { + t.Fatalf("Escape called SSH %d time(s), want 0", harness.service.sshCalls) + } + if harness.app.GetFocus() != harness.serverList { + t.Fatal("Escape did not restore focus to the server list") + } + screenText := simulationScreenText(harness.screen) + if !strings.Contains(screenText, "MAIN SERVER LIST") || strings.Contains(screenText, "Confirm Connection") { + t.Fatal("Escape did not restore the main server-list view") + } +} + +func TestConnectionConfirmationBlocksClicksBehindModal(t *testing.T) { + harness := newConnectionConfirmationHarness(t) + modal, pages := openConnectionConfirmationOverlay(harness) + harness.app.ForceDraw() + + if modal.InRect(0, 0) { + t.Fatal("test coordinate unexpectedly falls inside the centered modal") + } + consumed, _ := pages.MouseHandler()( + tview.MouseLeftDown, + tcell.NewEventMouse(0, 0, tcell.Button1, tcell.ModNone), + func(primitive tview.Primitive) { harness.app.SetFocus(primitive) }, + ) + if !consumed { + t.Fatal("click outside confirmation modal was allowed through to the main UI") + } +} + +func TestConnectionConfirmationMessage(t *testing.T) { + const want = "You are about to connect to \"a\\\"b\".\n\nTo edit this item instead, press E." + if got := connectionConfirmationMessage(`a"b`); got != want { + t.Fatalf("connectionConfirmationMessage() = %q, want %q", got, want) + } +} + +func TestConnectionConfirmationEscapesAliasTags(t *testing.T) { + harness := newConnectionConfirmationHarness(t) + harness.server.Alias = "[red]prod" + harness.view.showConnectionConfirmModal(harness.server) + harness.app.ForceDraw() + + if !strings.Contains(simulationScreenText(harness.screen), harness.server.Alias) { + t.Fatal("connection confirmation interpreted alias text as a tview color tag") + } +} + +func simulationScreenText(screen tcell.SimulationScreen) string { + width, height := screen.Size() + var text strings.Builder + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + mainc, _, _, _ := screen.GetContent(x, y) + text.WriteRune(mainc) + } + } + return text.String() +} diff --git a/internal/adapters/ui/server_form.go b/internal/adapters/ui/server_form.go index 286b47f..aa1df1d 100644 --- a/internal/adapters/ui/server_form.go +++ b/internal/adapters/ui/server_form.go @@ -67,6 +67,62 @@ type ServerForm struct { mainContainer *tview.Flex // Container for form and help panel } +const ( + activeFieldTextColor = tcell.Color232 + activeFieldBackgroundColor = tcell.Color252 +) + +// highlightedFormItem swaps field colors only while this item owns focus. +// tview reapplies form attributes on every draw, so this keeps the highlight +// stable without fighting the form's draw cycle. +type highlightedFormItem struct { + tview.FormItem +} + +func (item *highlightedFormItem) SetFormAttributes(labelWidth int, labelColor, bgColor, fieldTextColor, fieldBgColor tcell.Color) tview.FormItem { + if item.HasFocus() { + fieldTextColor = activeFieldTextColor + fieldBgColor = activeFieldBackgroundColor + } + item.FormItem.SetFormAttributes(labelWidth, labelColor, bgColor, fieldTextColor, fieldBgColor) + return item +} + +func highlightFormItem(item tview.FormItem) tview.FormItem { + return &highlightedFormItem{FormItem: item} +} + +func unwrapFormItem(item tview.FormItem) tview.FormItem { + if highlighted, ok := item.(*highlightedFormItem); ok { + return highlighted.FormItem + } + return item +} + +func moveFormFocus(form *tview.Form, direction int) bool { + itemIndex, buttonIndex := form.GetFocusedItemIndex() + if itemIndex < 0 || buttonIndex >= 0 { + return false + } + + // Arrow navigation is a form-level action for editable text cells. Keep + // dropdown arrows available for changing the selected option. + if _, ok := unwrapFormItem(form.GetFormItem(itemIndex)).(*tview.InputField); !ok { + return false + } + + total := form.GetFormItemCount() + form.GetButtonCount() + if total == 0 { + return false + } + next := (itemIndex + direction) % total + if next < 0 { + next += total + } + form.SetFocus(next) + return true +} + func NewServerForm(mode ServerFormMode, original *domain.Server) *ServerForm { // Create help panel helpPanel := tview.NewTextView(). @@ -172,7 +228,7 @@ func (sf *ServerForm) build() { hintBar := tview.NewTextView().SetDynamicColors(true) hintBar.SetBackgroundColor(tcell.Color235) hintBar.SetTextAlign(tview.AlignCenter) - hintBar.SetText("[white]^H/^L[-] Navigate • [white]^S[-] Save • [white]Esc[-] Cancel") + hintBar.SetText("[white]Tab/Shift+Tab[-] Tabs • [white]^S[-] Save • [white]Esc[-] Cancel") // Setup main container - header at top, hint bar at bottom sf.Flex.AddItem(sf.header, 2, 0, false). @@ -528,15 +584,7 @@ func (sf *ServerForm) setupKeyboardShortcuts() { // Check for Ctrl key combinations with regular keys if event.Key() == tcell.KeyRune && event.Modifiers()&tcell.ModCtrl != 0 { - switch event.Rune() { - case 'h', 'H', 8: // 8 is ASCII for Ctrl+H (backspace) - // Ctrl+H: Previous tab - sf.prevTab() - return nil - case 'l', 'L', 12: // 12 is ASCII for Ctrl+L (form feed) - // Ctrl+L: Next tab - sf.nextTab() - return nil + switch commandKey(event) { case 's', 'S', 19: // 19 is ASCII for Ctrl+S // Ctrl+S: Save sf.handleSave() @@ -555,14 +603,12 @@ func (sf *ServerForm) setupKeyboardShortcuts() { // ESC: Cancel sf.handleCancel() return nil - case tcell.KeyCtrlH: - // Ctrl+H: Previous tab (backup handler) - sf.prevTab() - return nil - case tcell.KeyCtrlL: - // Ctrl+L: Next tab (backup handler) + case tcell.KeyTab: sf.nextTab() return nil + case tcell.KeyBacktab: + sf.prevTab() + return nil default: // Pass through all other keys } @@ -576,13 +622,7 @@ func (sf *ServerForm) setupFormShortcuts(form *tview.Form) { form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { // Check for Ctrl key combinations if event.Key() == tcell.KeyRune && event.Modifiers()&tcell.ModCtrl != 0 { - switch event.Rune() { - case 'h', 'H', 8: // Ctrl+H: Previous tab - sf.prevTab() - return nil - case 'l', 'L', 12: // Ctrl+L: Next tab - sf.nextTab() - return nil + switch commandKey(event) { case 's', 'S', 19: // Ctrl+S: Save sf.handleSave() return nil @@ -595,12 +635,20 @@ func (sf *ServerForm) setupFormShortcuts(form *tview.Form) { case tcell.KeyEscape: sf.handleCancel() return nil - case tcell.KeyCtrlH: - sf.prevTab() - return nil - case tcell.KeyCtrlL: + case tcell.KeyTab: sf.nextTab() return nil + case tcell.KeyBacktab: + sf.prevTab() + return nil + case tcell.KeyDown: + if moveFormFocus(form, 1) { + return nil + } + case tcell.KeyUp: + if moveFormFocus(form, -1) { + return nil + } case tcell.KeyCtrlS: sf.handleSave() return nil @@ -958,7 +1006,37 @@ func (sf *ServerForm) addDropDownWithHelp(form *tview.Form, label, fieldName str sf.updateHelp(fieldName) }) - form.AddFormItem(dropdown) + // Keep option navigation cyclic while the dropdown list is open. When the + // list is closed, pass the first arrow event through so tview can open it. + dropdown.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if !dropdown.IsOpen() { + return event + } + + var direction int + switch event.Key() { + case tcell.KeyDown: + direction = 1 + case tcell.KeyUp: + direction = -1 + default: + return event + } + + count := dropdown.GetOptionCount() + if count == 0 { + return nil + } + current, _ := dropdown.GetCurrentOption() + next := (current + direction) % count + if next < 0 { + next += count + } + dropdown.SetCurrentOption(next) + return nil + }) + + form.AddFormItem(highlightFormItem(dropdown)) } // addInputFieldWithHelp adds a regular input field with help support @@ -977,7 +1055,7 @@ func (sf *ServerForm) addInputFieldWithHelp(form *tview.Form, label, fieldName, sf.updateHelp(fieldName) }) - form.AddFormItem(field) + form.AddFormItem(highlightFormItem(field)) return field } @@ -1016,7 +1094,7 @@ func (sf *ServerForm) addValidatedInputField(form *tview.Form, label, fieldName, sf.validateField(fieldName, field.GetText()) }) - form.AddFormItem(field) + form.AddFormItem(highlightFormItem(field)) return field } @@ -1740,7 +1818,7 @@ func (sf *ServerForm) getFormData() ServerFormData { getFieldText := func(fieldName string) string { for _, form := range sf.forms { for i := 0; i < form.GetFormItemCount(); i++ { - if field, ok := form.GetFormItem(i).(*tview.InputField); ok { + if field, ok := unwrapFormItem(form.GetFormItem(i)).(*tview.InputField); ok { label := strings.TrimSpace(field.GetLabel()) // Strip color tags from label for comparison // Labels can be: "Port:", "[red]Port:[-]", "[green]Port:[-]" @@ -1758,7 +1836,7 @@ func (sf *ServerForm) getFormData() ServerFormData { getDropdownValue := func(fieldName string) string { for _, form := range sf.forms { for i := 0; i < form.GetFormItemCount(); i++ { - if dropdown, ok := form.GetFormItem(i).(*tview.DropDown); ok { + if dropdown, ok := unwrapFormItem(form.GetFormItem(i)).(*tview.DropDown); ok { label := strings.TrimSpace(dropdown.GetLabel()) // Strip color tags from label for comparison cleanLabel := stripColorTags(label) @@ -1971,7 +2049,7 @@ func (sf *ServerForm) handleCancel() { // Set up keyboard shortcuts for the modal modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Rune() { + switch commandKey(event) { case 's', 'S': if sf.handleSave() { // Save successful @@ -1980,12 +2058,12 @@ func (sf *ServerForm) handleCancel() { sf.app.SetRoot(sf.Flex, true) } return nil - case 'd', 'D': + case 'd': if sf.onCancel != nil { sf.onCancel() } return nil - case 'c', 'C': + case 'c': sf.app.SetRoot(sf.Flex, true) return nil } diff --git a/internal/adapters/ui/tui.go b/internal/adapters/ui/tui.go index d938e6f..d391e1d 100644 --- a/internal/adapters/ui/tui.go +++ b/internal/adapters/ui/tui.go @@ -65,6 +65,10 @@ func (t *tui) Run() error { } }() t.app.EnableMouse(true) + t.app.SetBeforeDrawFunc(func(screen tcell.Screen) bool { + configureCursor(screen) + return false + }) t.initializeTheme().buildComponents().buildLayout().bindEvents().loadInitialData() t.app.SetRoot(t.root, true) t.logger.Infow("starting TUI application", "version", t.version, "commit", t.commit) @@ -75,6 +79,12 @@ func (t *tui) Run() error { return nil } +func configureCursor(screen tcell.Screen) { + if screen != nil { + screen.SetCursorStyle(tcell.CursorStyleBlinkingBlock) + } +} + func (t *tui) initializeTheme() *tui { tview.Styles.PrimitiveBackgroundColor = tcell.Color232 tview.Styles.ContrastBackgroundColor = tcell.Color235