From bae07cf046bfa54b39f518dfef8c50bd091bc1c4 Mon Sep 17 00:00:00 2001 From: Jacob Shufro Date: Fri, 19 Jun 2026 12:13:53 -0400 Subject: [PATCH 1/3] Make it so esc saves changes before exiting page when modifying text boxes --- rocketpool-cli/service/config/config-form.go | 211 ++++++------------ .../service/config/settings-metrics.go | 12 +- .../service/config/settings-smartnode.go | 20 +- .../service/config/standard-layout.go | 33 ++- 4 files changed, 99 insertions(+), 177 deletions(-) diff --git a/rocketpool-cli/service/config/config-form.go b/rocketpool-cli/service/config/config-form.go index 677221aa2..c23bd4d82 100644 --- a/rocketpool-cli/service/config/config-form.go +++ b/rocketpool-cli/service/config/config-form.go @@ -17,6 +17,42 @@ type parameterizedFormItem struct { item tview.FormItem } +func (pfi *parameterizedFormItem) commit() { + switch pfi.item.(type) { + case *tview.Checkbox: + pfi.parameter.Value = pfi.item.(*tview.Checkbox).IsChecked() + case *tview.InputField: + var err error + inputField := pfi.item.(*tview.InputField) + switch pfi.parameter.Type { + case cfgtypes.ParameterType_Int: + pfi.parameter.Value, err = strconv.ParseInt(inputField.GetText(), 0, 0) + if err != nil { + // TODO: show error modal? + inputField.SetText("") + } + case cfgtypes.ParameterType_Uint: + pfi.parameter.Value, err = strconv.ParseUint(inputField.GetText(), 0, 0) + if err != nil { + // TODO: show error modal? + inputField.SetText("") + } + case cfgtypes.ParameterType_Uint16: + pfi.parameter.Value, err = strconv.ParseUint(inputField.GetText(), 0, 16) + if err != nil { + // TODO: show error modal? + inputField.SetText("") + } + case cfgtypes.ParameterType_String, cfgtypes.ParameterType_Float: + pfi.parameter.Value = strings.TrimSpace(inputField.GetText()) + default: + panic(fmt.Sprintf("Unknown parameter type for text field %v", pfi.parameter.Type)) + } + default: + panic(fmt.Sprintf("Unknown form item type %v", pfi.item)) + } +} + // Create a list of form items based on a set of parameters func createParameterizedFormItems(params []*cfgtypes.Parameter, descriptionBox *tview.TextView) []*parameterizedFormItem { formItems := []*parameterizedFormItem{} @@ -25,18 +61,12 @@ func createParameterizedFormItems(params []*cfgtypes.Parameter, descriptionBox * switch param.Type { case cfgtypes.ParameterType_Bool: item = createParameterizedCheckbox(param) - case cfgtypes.ParameterType_Int: + case cfgtypes.ParameterType_Int, cfgtypes.ParameterType_Uint, cfgtypes.ParameterType_Uint16: item = createParameterizedIntField(param) - case cfgtypes.ParameterType_Uint: - item = createParameterizedUintField(param) - case cfgtypes.ParameterType_Uint16: - item = createParameterizedUint16Field(param) - case cfgtypes.ParameterType_String: + case cfgtypes.ParameterType_String, cfgtypes.ParameterType_Float: item = createParameterizedStringField(param) case cfgtypes.ParameterType_Choice: item = createParameterizedDropDown(param, descriptionBox) - case cfgtypes.ParameterType_Float: - item = createParameterizedStringField(param) default: panic(fmt.Sprintf("Unknown parameter type %v", param)) } @@ -50,142 +80,56 @@ func createParameterizedFormItems(params []*cfgtypes.Parameter, descriptionBox * func createParameterizedCheckbox(param *cfgtypes.Parameter) *parameterizedFormItem { item := tview.NewCheckbox(). SetLabel(param.Name). - SetChecked(param.Value == true). - SetChangedFunc(func(checked bool) { - param.Value = checked - }) - item.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyDown, tcell.KeyTab: - return tcell.NewEventKey(tcell.KeyTab, 0, 0) - case tcell.KeyUp, tcell.KeyBacktab: - return tcell.NewEventKey(tcell.KeyBacktab, 0, 0) - default: - return event - } - }) - - return ¶meterizedFormItem{ + SetChecked(param.Value == true) + out := ¶meterizedFormItem{ parameter: param, item: item, } -} - -// Create a standard int field -func createParameterizedIntField(param *cfgtypes.Parameter) *parameterizedFormItem { - item := tview.NewInputField(). - SetLabel(param.Name). - SetAcceptanceFunc(tview.InputFieldInteger) - item.SetDoneFunc(func(key tcell.Key) { - if key == tcell.KeyEscape { - item.SetText("") - } else { - value, err := strconv.ParseInt(item.GetText(), 0, 0) - if err != nil { - // TODO: show error modal? - item.SetText("") - } else { - param.Value = int(value) - } - } - }) - item.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyDown, tcell.KeyTab: - return tcell.NewEventKey(tcell.KeyTab, 0, 0) - case tcell.KeyUp, tcell.KeyBacktab: - return tcell.NewEventKey(tcell.KeyBacktab, 0, 0) - default: - return event - } + item.SetInputCapture(navCapture) + item.SetChangedFunc(func(checked bool) { + out.commit() }) - return ¶meterizedFormItem{ - parameter: param, - item: item, + return out +} + +func navCapture(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyDown, tcell.KeyTab: + return tcell.NewEventKey(tcell.KeyTab, 0, 0) + case tcell.KeyUp, tcell.KeyBacktab: + return tcell.NewEventKey(tcell.KeyBacktab, 0, 0) } + return event } -// Create a standard uint field -func createParameterizedUintField(param *cfgtypes.Parameter) *parameterizedFormItem { +// Create a standard int field +func createParameterizedIntField(param *cfgtypes.Parameter) *parameterizedFormItem { item := tview.NewInputField(). SetLabel(param.Name). SetAcceptanceFunc(tview.InputFieldInteger) - item.SetDoneFunc(func(key tcell.Key) { - if key == tcell.KeyEscape { - item.SetText("") - } else { - value, err := strconv.ParseUint(item.GetText(), 0, 0) - if err != nil { - // TODO: show error modal? - item.SetText("") - } else { - param.Value = int(value) - } - } - }) - item.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyDown, tcell.KeyTab: - return tcell.NewEventKey(tcell.KeyTab, 0, 0) - case tcell.KeyUp, tcell.KeyBacktab: - return tcell.NewEventKey(tcell.KeyBacktab, 0, 0) - default: - return event - } - }) - - return ¶meterizedFormItem{ + out := ¶meterizedFormItem{ parameter: param, item: item, } -} - -// Create a standard uint16 field -func createParameterizedUint16Field(param *cfgtypes.Parameter) *parameterizedFormItem { - item := tview.NewInputField(). - SetLabel(param.Name). - SetAcceptanceFunc(tview.InputFieldInteger) + item.SetInputCapture(navCapture) item.SetDoneFunc(func(key tcell.Key) { - if key == tcell.KeyEscape { - item.SetText("") - } else { - value, err := strconv.ParseUint(item.GetText(), 0, 16) - if err != nil { - // TODO: show error modal? - item.SetText("") - } else { - param.Value = int(value) - } - } - }) - item.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyDown, tcell.KeyTab: - return tcell.NewEventKey(tcell.KeyTab, 0, 0) - case tcell.KeyUp, tcell.KeyBacktab: - return tcell.NewEventKey(tcell.KeyBacktab, 0, 0) - default: - return event - } + out.commit() }) - return ¶meterizedFormItem{ - parameter: param, - item: item, - } + return out } // Create a standard string field func createParameterizedStringField(param *cfgtypes.Parameter) *parameterizedFormItem { item := tview.NewInputField(). SetLabel(param.Name) + out := ¶meterizedFormItem{ + parameter: param, + item: item, + } item.SetDoneFunc(func(key tcell.Key) { - if key == tcell.KeyEscape { - item.SetText("") - } else { - param.Value = strings.TrimSpace(item.GetText()) - } + out.commit() }) item.SetAcceptanceFunc(func(textToCheck string, lastChar rune) bool { if param.MaxLength > 0 { @@ -196,21 +140,9 @@ func createParameterizedStringField(param *cfgtypes.Parameter) *parameterizedFor // TODO: regex support return true }) - item.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyDown, tcell.KeyTab: - return tcell.NewEventKey(tcell.KeyTab, 0, 0) - case tcell.KeyUp, tcell.KeyBacktab: - return tcell.NewEventKey(tcell.KeyBacktab, 0, 0) - default: - return event - } - }) + item.SetInputCapture(navCapture) - return ¶meterizedFormItem{ - parameter: param, - item: item, - } + return out } // Create a standard choice field @@ -233,16 +165,7 @@ func createParameterizedDropDown(param *cfgtypes.Parameter, descriptionBox *tvie descriptionBox.SetText(descriptions[index]) }) item.SetTextOptions(" ", " ", "", "", "") - item.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyDown, tcell.KeyTab: - return tcell.NewEventKey(tcell.KeyTab, 0, 0) - case tcell.KeyUp, tcell.KeyBacktab: - return tcell.NewEventKey(tcell.KeyBacktab, 0, 0) - default: - return event - } - }) + item.SetInputCapture(navCapture) list := item.GetList() list.SetSelectedBackgroundColor(tcell.Color46) list.SetSelectedTextColor(tcell.ColorBlack) diff --git a/rocketpool-cli/service/config/settings-metrics.go b/rocketpool-cli/service/config/settings-metrics.go index 6f20177fc..e7dcc9ec1 100644 --- a/rocketpool-cli/service/config/settings-metrics.go +++ b/rocketpool-cli/service/config/settings-metrics.go @@ -65,12 +65,12 @@ func (configPage *MetricsConfigPage) createContent() { // Set up the form items configPage.enableMetricsBox = createParameterizedCheckbox(&configPage.masterConfig.EnableMetrics) configPage.enableOdaoMetricsBox = createParameterizedCheckbox(&configPage.masterConfig.EnableODaoMetrics) - configPage.ecMetricsPortBox = createParameterizedUint16Field(&configPage.masterConfig.EcMetricsPort) - configPage.bnMetricsPortBox = createParameterizedUint16Field(&configPage.masterConfig.BnMetricsPort) - configPage.vcMetricsPortBox = createParameterizedUint16Field(&configPage.masterConfig.VcMetricsPort) - configPage.nodeMetricsPortBox = createParameterizedUint16Field(&configPage.masterConfig.NodeMetricsPort) - configPage.exporterMetricsPortBox = createParameterizedUint16Field(&configPage.masterConfig.ExporterMetricsPort) - configPage.watchtowerMetricsPortBox = createParameterizedUint16Field(&configPage.masterConfig.WatchtowerMetricsPort) + configPage.ecMetricsPortBox = createParameterizedIntField(&configPage.masterConfig.EcMetricsPort) + configPage.bnMetricsPortBox = createParameterizedIntField(&configPage.masterConfig.BnMetricsPort) + configPage.vcMetricsPortBox = createParameterizedIntField(&configPage.masterConfig.VcMetricsPort) + configPage.nodeMetricsPortBox = createParameterizedIntField(&configPage.masterConfig.NodeMetricsPort) + configPage.exporterMetricsPortBox = createParameterizedIntField(&configPage.masterConfig.ExporterMetricsPort) + configPage.watchtowerMetricsPortBox = createParameterizedIntField(&configPage.masterConfig.WatchtowerMetricsPort) configPage.grafanaItems = createParameterizedFormItems(configPage.masterConfig.Grafana.GetParameters(), configPage.layout.descriptionBox) configPage.prometheusItems = createParameterizedFormItems(configPage.masterConfig.Prometheus.GetParameters(), configPage.layout.descriptionBox) configPage.exporterItems = createParameterizedFormItems(configPage.masterConfig.Exporter.GetParameters(), configPage.layout.descriptionBox) diff --git a/rocketpool-cli/service/config/settings-smartnode.go b/rocketpool-cli/service/config/settings-smartnode.go index 62de274f3..131df5bcb 100644 --- a/rocketpool-cli/service/config/settings-smartnode.go +++ b/rocketpool-cli/service/config/settings-smartnode.go @@ -1,8 +1,6 @@ package config import ( - "github.com/gdamore/tcell/v2" - "github.com/rocket-pool/smartnode/shared/services/config" cfgtypes "github.com/rocket-pool/smartnode/shared/types/config" ) @@ -49,23 +47,7 @@ func (configPage *SmartnodeConfigPage) createContent() { layout.createForm(&masterConfig.Smartnode.Network, "Smart Node and TX Fee Settings") // Return to the home page after pressing Escape - layout.form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - // Close all dropdowns and break if one was open - for _, param := range configPage.layout.parameters { - dropDown, ok := param.item.(*DropDown) - if ok && dropDown.open { - dropDown.CloseList(configPage.home.md.app) - return nil - } - } - - // Return to the home page - configPage.home.md.setPage(configPage.home.homePage) - return nil - } - return event - }) + layout.form.SetInputCapture(layout.getInputCapture(configPage.home.md, configPage.home.homePage)) // Set up the form items params := append(masterConfig.Smartnode.GetParameters(), &masterConfig.EnableIPv6, &masterConfig.Alertmanager.ShowAlertsOnCLI) diff --git a/rocketpool-cli/service/config/standard-layout.go b/rocketpool-cli/service/config/standard-layout.go index 84c149770..50eb0004f 100644 --- a/rocketpool-cli/service/config/standard-layout.go +++ b/rocketpool-cli/service/config/standard-layout.go @@ -210,22 +210,39 @@ func (layout *standardLayout) mapParameterizedFormItems(params ...*parameterized } } -// Sets up a handler to return to the specified homePage when the user presses escape on the layout. -func (layout *standardLayout) setupEscapeReturnHomeHandler(md *MainDisplay, homePage *page) { - layout.grid.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - // Return to the home page +func (layout *standardLayout) getInputCapture(md *MainDisplay, prev *page) func(event *tcell.EventKey) *tcell.EventKey { + return func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEsc { // Close all dropdowns and break if one was open + // Save the current modifications to text parameters for _, param := range layout.parameters { - dropDown, ok := param.item.(*DropDown) - if ok && dropDown.open { + formItem := param.item + if !formItem.HasFocus() { + continue + } + + // Close the dropdown if this field is one and it is open + if dropDown, ok := param.item.(*DropDown); ok && dropDown.open { dropDown.CloseList(md.app) return nil } + + // Save the text if this field is one + if _, ok := param.item.(*tview.InputField); ok { + param.commit() + // Exit the loop to return to the home page + break + } } - md.setPage(homePage) + + md.setPage(prev) return nil } return event - }) + } +} + +// Sets up a handler to return to the specified homePage when the user presses escape on the layout. +func (layout *standardLayout) setupEscapeReturnHomeHandler(md *MainDisplay, homePage *page) { + layout.grid.SetInputCapture(layout.getInputCapture(md, homePage)) } From 8532c6f158769bbbd12f19a420cbd6a53c40f79f Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:51:04 -0300 Subject: [PATCH 2/3] Update str to let users know esc will save changes --- rocketpool-cli/service/config/standard-layout.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rocketpool-cli/service/config/standard-layout.go b/rocketpool-cli/service/config/standard-layout.go index 50eb0004f..bc870b34e 100644 --- a/rocketpool-cli/service/config/standard-layout.go +++ b/rocketpool-cli/service/config/standard-layout.go @@ -147,7 +147,7 @@ func (layout *standardLayout) createSettingFooter() { panic(fmt.Errorf("error writing nav string 1: %w", err)) } - navString2 := "Esc: Go Back to Categories" + navString2 := "Esc: Go Back to Categories (saves changes)" navTextView2 := tview.NewTextView(). SetDynamicColors(false). SetRegions(false). From 1bfeca583a7c84e7ccb5ca0a1b47d7cfb2993b8c Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:22:10 -0300 Subject: [PATCH 3/3] Add onCommitError message and block navigation if there is an error --- rocketpool-cli/service/config/addon-gww.go | 2 +- .../service/config/addon-rescuenode.go | 2 +- rocketpool-cli/service/config/config-form.go | 53 +++++----- rocketpool-cli/service/config/form.go | 13 +++ .../service/config/settings-alerting.go | 2 +- .../service/config/settings-commit-boost.go | 4 +- .../service/config/settings-consensus.go | 22 ++--- .../service/config/settings-execution.go | 12 +-- .../service/config/settings-fallback.go | 4 +- .../service/config/settings-metrics.go | 8 +- .../service/config/settings-mev-boost.go | 4 +- .../config/settings-native-fallback.go | 2 +- .../config/settings-native-smartnode.go | 2 +- .../service/config/settings-native.go | 2 +- .../service/config/settings-smartnode.go | 2 +- .../service/config/standard-layout.go | 99 +++++++++++++++++-- 16 files changed, 166 insertions(+), 67 deletions(-) diff --git a/rocketpool-cli/service/config/addon-gww.go b/rocketpool-cli/service/config/addon-gww.go index 391854d2f..3ad261fb8 100644 --- a/rocketpool-cli/service/config/addon-gww.go +++ b/rocketpool-cli/service/config/addon-gww.go @@ -68,7 +68,7 @@ func (configPage *AddonGwwPage) createContent() { // Set up the form items configPage.enabledBox = createParameterizedCheckbox(enabledParam) - configPage.otherParams = createParameterizedFormItems(otherParams, configPage.layout.descriptionBox) + configPage.otherParams = createParameterizedFormItems(otherParams, configPage.layout) // Map the parameters to the form items in the layout configPage.layout.mapParameterizedFormItems(configPage.enabledBox) diff --git a/rocketpool-cli/service/config/addon-rescuenode.go b/rocketpool-cli/service/config/addon-rescuenode.go index 6d055359b..c0e04165e 100644 --- a/rocketpool-cli/service/config/addon-rescuenode.go +++ b/rocketpool-cli/service/config/addon-rescuenode.go @@ -67,7 +67,7 @@ func (configPage *AddonRescueNodePage) createContent() { // Set up the form items configPage.enabledBox = createParameterizedCheckbox(enabledParam) - configPage.otherParams = createParameterizedFormItems(otherParams, configPage.layout.descriptionBox) + configPage.otherParams = createParameterizedFormItems(otherParams, configPage.layout) // Map the parameters to the form items in the layout configPage.layout.mapParameterizedFormItems(configPage.enabledBox) diff --git a/rocketpool-cli/service/config/config-form.go b/rocketpool-cli/service/config/config-form.go index c23bd4d82..5642b42b4 100644 --- a/rocketpool-cli/service/config/config-form.go +++ b/rocketpool-cli/service/config/config-form.go @@ -13,14 +13,28 @@ import ( // A form item linked to a Parameter type parameterizedFormItem struct { - parameter *cfgtypes.Parameter - item tview.FormItem + parameter *cfgtypes.Parameter + item tview.FormItem + onCommitError func(message string) +} + +func (pfi *parameterizedFormItem) reportCommitError(message string) { + if pfi.onCommitError != nil { + pfi.onCommitError(message) + } +} + +func (pfi *parameterizedFormItem) clearCommitError() { + if pfi.onCommitError != nil { + pfi.onCommitError("") + } } func (pfi *parameterizedFormItem) commit() { switch pfi.item.(type) { case *tview.Checkbox: pfi.parameter.Value = pfi.item.(*tview.Checkbox).IsChecked() + pfi.clearCommitError() case *tview.InputField: var err error inputField := pfi.item.(*tview.InputField) @@ -28,33 +42,34 @@ func (pfi *parameterizedFormItem) commit() { case cfgtypes.ParameterType_Int: pfi.parameter.Value, err = strconv.ParseInt(inputField.GetText(), 0, 0) if err != nil { - // TODO: show error modal? - inputField.SetText("") + pfi.reportCommitError(fmt.Sprintf("INVALID INTEGER VALUE FOR %s", pfi.parameter.Name)) + return } case cfgtypes.ParameterType_Uint: pfi.parameter.Value, err = strconv.ParseUint(inputField.GetText(), 0, 0) if err != nil { - // TODO: show error modal? - inputField.SetText("") + pfi.reportCommitError(fmt.Sprintf("INVALID UNSIGNED INTEGER VALUE FOR %s", pfi.parameter.Name)) + return } case cfgtypes.ParameterType_Uint16: pfi.parameter.Value, err = strconv.ParseUint(inputField.GetText(), 0, 16) if err != nil { - // TODO: show error modal? - inputField.SetText("") + pfi.reportCommitError(fmt.Sprintf("INVALID VALUE FOR %s (MUST BE 0–65535)", pfi.parameter.Name)) + return } case cfgtypes.ParameterType_String, cfgtypes.ParameterType_Float: pfi.parameter.Value = strings.TrimSpace(inputField.GetText()) default: panic(fmt.Sprintf("Unknown parameter type for text field %v", pfi.parameter.Type)) } + pfi.clearCommitError() default: panic(fmt.Sprintf("Unknown form item type %v", pfi.item)) } } // Create a list of form items based on a set of parameters -func createParameterizedFormItems(params []*cfgtypes.Parameter, descriptionBox *tview.TextView) []*parameterizedFormItem { +func createParameterizedFormItems(params []*cfgtypes.Parameter, layout *standardLayout) []*parameterizedFormItem { formItems := []*parameterizedFormItem{} for _, param := range params { var item *parameterizedFormItem @@ -66,10 +81,14 @@ func createParameterizedFormItems(params []*cfgtypes.Parameter, descriptionBox * case cfgtypes.ParameterType_String, cfgtypes.ParameterType_Float: item = createParameterizedStringField(param) case cfgtypes.ParameterType_Choice: - item = createParameterizedDropDown(param, descriptionBox) + item = createParameterizedDropDown(param, layout.descriptionBox) default: panic(fmt.Sprintf("Unknown parameter type %v", param)) } + if layout != nil { + item.onCommitError = layout.showCommitError + layout.setItemNavCapture(item) + } formItems = append(formItems, item) } @@ -85,7 +104,6 @@ func createParameterizedCheckbox(param *cfgtypes.Parameter) *parameterizedFormIt parameter: param, item: item, } - item.SetInputCapture(navCapture) item.SetChangedFunc(func(checked bool) { out.commit() }) @@ -93,16 +111,6 @@ func createParameterizedCheckbox(param *cfgtypes.Parameter) *parameterizedFormIt return out } -func navCapture(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyDown, tcell.KeyTab: - return tcell.NewEventKey(tcell.KeyTab, 0, 0) - case tcell.KeyUp, tcell.KeyBacktab: - return tcell.NewEventKey(tcell.KeyBacktab, 0, 0) - } - return event -} - // Create a standard int field func createParameterizedIntField(param *cfgtypes.Parameter) *parameterizedFormItem { item := tview.NewInputField(). @@ -112,7 +120,6 @@ func createParameterizedIntField(param *cfgtypes.Parameter) *parameterizedFormIt parameter: param, item: item, } - item.SetInputCapture(navCapture) item.SetDoneFunc(func(key tcell.Key) { out.commit() }) @@ -140,7 +147,6 @@ func createParameterizedStringField(param *cfgtypes.Parameter) *parameterizedFor // TODO: regex support return true }) - item.SetInputCapture(navCapture) return out } @@ -165,7 +171,6 @@ func createParameterizedDropDown(param *cfgtypes.Parameter, descriptionBox *tvie descriptionBox.SetText(descriptions[index]) }) item.SetTextOptions(" ", " ", "", "", "") - item.SetInputCapture(navCapture) list := item.GetList() list.SetSelectedBackgroundColor(tcell.Color46) list.SetSelectedTextColor(tcell.ColorBlack) diff --git a/rocketpool-cli/service/config/form.go b/rocketpool-cli/service/config/form.go index 7c2d339be..657249790 100644 --- a/rocketpool-cli/service/config/form.go +++ b/rocketpool-cli/service/config/form.go @@ -61,6 +61,9 @@ type Form struct { // An optional function which is called when the form item changes. changed func(index int) + + // When set, navigation between form items is blocked while this returns true. + navigationBlocked func() bool } // NewForm returns a new form. @@ -355,6 +358,13 @@ func (f *Form) SetCancelFunc(callback func()) *Form { return f } +// SetNavigationBlocked sets a callback that blocks Tab/Enter/Backtab navigation +// between form items while it returns true. +func (f *Form) SetNavigationBlocked(blocked func() bool) *Form { + f.navigationBlocked = blocked + return f +} + // SetChangedFunc sets a handler which is called when the user moves to // another field in the form. func (f *Form) SetChangedFunc(callback func(index int)) *Form { @@ -574,6 +584,9 @@ func (f *Form) Focus(delegate func(p tview.Primitive)) { f.focusedElement = 0 } handler := func(key tcell.Key) { + if f.navigationBlocked != nil && f.navigationBlocked() { + return + } switch key { case tcell.KeyTab, tcell.KeyEnter: f.focusedElement++ diff --git a/rocketpool-cli/service/config/settings-alerting.go b/rocketpool-cli/service/config/settings-alerting.go index e3e8cbb2d..830e5c967 100644 --- a/rocketpool-cli/service/config/settings-alerting.go +++ b/rocketpool-cli/service/config/settings-alerting.go @@ -121,7 +121,7 @@ func (configPage *AlertingConfigPage) createContent() { configPage.layout.setupEscapeReturnHomeHandler(configPage.mainDisplay, configPage.homePage) // Set up the UI components - allItems := createParameterizedFormItems(configPage.masterConfig.Alertmanager.GetParameters(), configPage.layout.descriptionBox) + allItems := createParameterizedFormItems(configPage.masterConfig.Alertmanager.GetParameters(), configPage.layout) // Map the config parameters to the UI form items: configPage.layout.mapParameterizedFormItems(allItems...) diff --git a/rocketpool-cli/service/config/settings-commit-boost.go b/rocketpool-cli/service/config/settings-commit-boost.go index c55718838..abd59dd2c 100644 --- a/rocketpool-cli/service/config/settings-commit-boost.go +++ b/rocketpool-cli/service/config/settings-commit-boost.go @@ -73,8 +73,8 @@ func (configPage *CommitBoostConfigPage) createContent() { } externalParams := []*cfgtypes.Parameter{&configPage.masterConfig.CommitBoost.ExternalUrl} - configPage.localItems = createParameterizedFormItems(localParams, configPage.layout.descriptionBox) - configPage.externalItems = createParameterizedFormItems(externalParams, configPage.layout.descriptionBox) + configPage.localItems = createParameterizedFormItems(localParams, configPage.layout) + configPage.externalItems = createParameterizedFormItems(externalParams, configPage.layout) // Relay checkboxes - using CommitBoost's own relay parameters configPage.flashbotsBox = createParameterizedCheckbox(&configPage.masterConfig.CommitBoost.FlashbotsRelay) diff --git a/rocketpool-cli/service/config/settings-consensus.go b/rocketpool-cli/service/config/settings-consensus.go index 2dc320006..f5a2ce907 100644 --- a/rocketpool-cli/service/config/settings-consensus.go +++ b/rocketpool-cli/service/config/settings-consensus.go @@ -65,17 +65,17 @@ func (configPage *ConsensusConfigPage) createContent() { configPage.ccModeDropdown = createParameterizedDropDown(&configPage.masterConfig.ConsensusClientMode, configPage.layout.descriptionBox) configPage.ccDropdown = createParameterizedDropDown(&configPage.masterConfig.ConsensusClient, configPage.layout.descriptionBox) configPage.externalCcDropdown = createParameterizedDropDown(&configPage.masterConfig.ExternalConsensusClient, configPage.layout.descriptionBox) - configPage.ccCommonItems = createParameterizedFormItems(configPage.masterConfig.ConsensusCommon.GetParameters(), configPage.layout.descriptionBox) - configPage.lighthouseItems = createParameterizedFormItems(configPage.masterConfig.Lighthouse.GetParameters(), configPage.layout.descriptionBox) - configPage.lodestarItems = createParameterizedFormItems(configPage.masterConfig.Lodestar.GetParameters(), configPage.layout.descriptionBox) - configPage.nimbusItems = createParameterizedFormItems(configPage.masterConfig.Nimbus.GetParameters(), configPage.layout.descriptionBox) - configPage.prysmItems = createParameterizedFormItems(configPage.masterConfig.Prysm.GetParameters(), configPage.layout.descriptionBox) - configPage.tekuItems = createParameterizedFormItems(configPage.masterConfig.Teku.GetParameters(), configPage.layout.descriptionBox) - configPage.externalLighthouseItems = createParameterizedFormItems(configPage.masterConfig.ExternalLighthouse.GetParameters(), configPage.layout.descriptionBox) - configPage.externalNimbusItems = createParameterizedFormItems(configPage.masterConfig.ExternalNimbus.GetParameters(), configPage.layout.descriptionBox) - configPage.externalLodestarItems = createParameterizedFormItems(configPage.masterConfig.ExternalLodestar.GetParameters(), configPage.layout.descriptionBox) - configPage.externalPrysmItems = createParameterizedFormItems(configPage.masterConfig.ExternalPrysm.GetParameters(), configPage.layout.descriptionBox) - configPage.externalTekuItems = createParameterizedFormItems(configPage.masterConfig.ExternalTeku.GetParameters(), configPage.layout.descriptionBox) + configPage.ccCommonItems = createParameterizedFormItems(configPage.masterConfig.ConsensusCommon.GetParameters(), configPage.layout) + configPage.lighthouseItems = createParameterizedFormItems(configPage.masterConfig.Lighthouse.GetParameters(), configPage.layout) + configPage.lodestarItems = createParameterizedFormItems(configPage.masterConfig.Lodestar.GetParameters(), configPage.layout) + configPage.nimbusItems = createParameterizedFormItems(configPage.masterConfig.Nimbus.GetParameters(), configPage.layout) + configPage.prysmItems = createParameterizedFormItems(configPage.masterConfig.Prysm.GetParameters(), configPage.layout) + configPage.tekuItems = createParameterizedFormItems(configPage.masterConfig.Teku.GetParameters(), configPage.layout) + configPage.externalLighthouseItems = createParameterizedFormItems(configPage.masterConfig.ExternalLighthouse.GetParameters(), configPage.layout) + configPage.externalNimbusItems = createParameterizedFormItems(configPage.masterConfig.ExternalNimbus.GetParameters(), configPage.layout) + configPage.externalLodestarItems = createParameterizedFormItems(configPage.masterConfig.ExternalLodestar.GetParameters(), configPage.layout) + configPage.externalPrysmItems = createParameterizedFormItems(configPage.masterConfig.ExternalPrysm.GetParameters(), configPage.layout) + configPage.externalTekuItems = createParameterizedFormItems(configPage.masterConfig.ExternalTeku.GetParameters(), configPage.layout) // Map the parameters to the form items in the layout configPage.layout.mapParameterizedFormItems(configPage.ccModeDropdown, configPage.ccDropdown, configPage.externalCcDropdown) diff --git a/rocketpool-cli/service/config/settings-execution.go b/rocketpool-cli/service/config/settings-execution.go index 747b628fa..786b709b7 100644 --- a/rocketpool-cli/service/config/settings-execution.go +++ b/rocketpool-cli/service/config/settings-execution.go @@ -58,12 +58,12 @@ func (configPage *ExecutionConfigPage) createContent() { // Set up the form items configPage.ecModeDropdown = createParameterizedDropDown(&configPage.masterConfig.ExecutionClientMode, configPage.layout.descriptionBox) configPage.ecDropdown = createParameterizedDropDown(&configPage.masterConfig.ExecutionClient, configPage.layout.descriptionBox) - configPage.ecCommonItems = createParameterizedFormItems(configPage.masterConfig.ExecutionCommon.GetParameters(), configPage.layout.descriptionBox) - configPage.gethItems = createParameterizedFormItems(configPage.masterConfig.Geth.GetParameters(), configPage.layout.descriptionBox) - configPage.nethermindItems = createParameterizedFormItems(configPage.masterConfig.Nethermind.GetParameters(), configPage.layout.descriptionBox) - configPage.besuItems = createParameterizedFormItems(configPage.masterConfig.Besu.GetParameters(), configPage.layout.descriptionBox) - configPage.rethItems = createParameterizedFormItems(configPage.masterConfig.Reth.GetParameters(), configPage.layout.descriptionBox) - configPage.externalEcItems = createParameterizedFormItems(configPage.masterConfig.ExternalExecution.GetParameters(), configPage.layout.descriptionBox) + configPage.ecCommonItems = createParameterizedFormItems(configPage.masterConfig.ExecutionCommon.GetParameters(), configPage.layout) + configPage.gethItems = createParameterizedFormItems(configPage.masterConfig.Geth.GetParameters(), configPage.layout) + configPage.nethermindItems = createParameterizedFormItems(configPage.masterConfig.Nethermind.GetParameters(), configPage.layout) + configPage.besuItems = createParameterizedFormItems(configPage.masterConfig.Besu.GetParameters(), configPage.layout) + configPage.rethItems = createParameterizedFormItems(configPage.masterConfig.Reth.GetParameters(), configPage.layout) + configPage.externalEcItems = createParameterizedFormItems(configPage.masterConfig.ExternalExecution.GetParameters(), configPage.layout) // Map the parameters to the form items in the layout configPage.layout.mapParameterizedFormItems(configPage.ecModeDropdown, configPage.ecDropdown) diff --git a/rocketpool-cli/service/config/settings-fallback.go b/rocketpool-cli/service/config/settings-fallback.go index 6fc55c23e..87d6f2568 100644 --- a/rocketpool-cli/service/config/settings-fallback.go +++ b/rocketpool-cli/service/config/settings-fallback.go @@ -56,8 +56,8 @@ func (configPage *FallbackConfigPage) createContent() { // Set up the form items configPage.useFallbackBox = createParameterizedCheckbox(&configPage.masterConfig.UseFallbackClients) configPage.reconnectDelay = createParameterizedStringField(&configPage.masterConfig.ReconnectDelay) - configPage.fallbackNormalItems = createParameterizedFormItems(configPage.masterConfig.FallbackNormal.GetParameters(), configPage.layout.descriptionBox) - configPage.fallbackPrysmItems = createParameterizedFormItems(configPage.masterConfig.FallbackPrysm.GetParameters(), configPage.layout.descriptionBox) + configPage.fallbackNormalItems = createParameterizedFormItems(configPage.masterConfig.FallbackNormal.GetParameters(), configPage.layout) + configPage.fallbackPrysmItems = createParameterizedFormItems(configPage.masterConfig.FallbackPrysm.GetParameters(), configPage.layout) // Map the parameters to the form items in the layout configPage.layout.mapParameterizedFormItems(configPage.useFallbackBox, configPage.reconnectDelay) diff --git a/rocketpool-cli/service/config/settings-metrics.go b/rocketpool-cli/service/config/settings-metrics.go index e7dcc9ec1..35202362f 100644 --- a/rocketpool-cli/service/config/settings-metrics.go +++ b/rocketpool-cli/service/config/settings-metrics.go @@ -71,11 +71,11 @@ func (configPage *MetricsConfigPage) createContent() { configPage.nodeMetricsPortBox = createParameterizedIntField(&configPage.masterConfig.NodeMetricsPort) configPage.exporterMetricsPortBox = createParameterizedIntField(&configPage.masterConfig.ExporterMetricsPort) configPage.watchtowerMetricsPortBox = createParameterizedIntField(&configPage.masterConfig.WatchtowerMetricsPort) - configPage.grafanaItems = createParameterizedFormItems(configPage.masterConfig.Grafana.GetParameters(), configPage.layout.descriptionBox) - configPage.prometheusItems = createParameterizedFormItems(configPage.masterConfig.Prometheus.GetParameters(), configPage.layout.descriptionBox) - configPage.exporterItems = createParameterizedFormItems(configPage.masterConfig.Exporter.GetParameters(), configPage.layout.descriptionBox) + configPage.grafanaItems = createParameterizedFormItems(configPage.masterConfig.Grafana.GetParameters(), configPage.layout) + configPage.prometheusItems = createParameterizedFormItems(configPage.masterConfig.Prometheus.GetParameters(), configPage.layout) + configPage.exporterItems = createParameterizedFormItems(configPage.masterConfig.Exporter.GetParameters(), configPage.layout) configPage.enableBitflyNodeMetricsBox = createParameterizedCheckbox(&configPage.masterConfig.EnableBitflyNodeMetrics) - configPage.bitflyNodeMetricsItems = createParameterizedFormItems(configPage.masterConfig.BitflyNodeMetrics.GetParameters(), configPage.layout.descriptionBox) + configPage.bitflyNodeMetricsItems = createParameterizedFormItems(configPage.masterConfig.BitflyNodeMetrics.GetParameters(), configPage.layout) // Map the parameters to the form items in the layout configPage.layout.mapParameterizedFormItems(configPage.enableMetricsBox, configPage.enableOdaoMetricsBox, configPage.ecMetricsPortBox, configPage.bnMetricsPortBox, configPage.vcMetricsPortBox, configPage.nodeMetricsPortBox, configPage.exporterMetricsPortBox, configPage.watchtowerMetricsPortBox) diff --git a/rocketpool-cli/service/config/settings-mev-boost.go b/rocketpool-cli/service/config/settings-mev-boost.go index 5db65d5b5..4b19de7e2 100644 --- a/rocketpool-cli/service/config/settings-mev-boost.go +++ b/rocketpool-cli/service/config/settings-mev-boost.go @@ -78,8 +78,8 @@ func (configPage *MevBoostConfigPage) createContent() { } externalParams := []*cfgtypes.Parameter{&configPage.masterConfig.MevBoost.ExternalUrl} - configPage.localItems = createParameterizedFormItems(localParams, configPage.layout.descriptionBox) - configPage.externalItems = createParameterizedFormItems(externalParams, configPage.layout.descriptionBox) + configPage.localItems = createParameterizedFormItems(localParams, configPage.layout) + configPage.externalItems = createParameterizedFormItems(externalParams, configPage.layout) configPage.flashbotsBox = createParameterizedCheckbox(&configPage.masterConfig.MevBoost.FlashbotsRelay) configPage.bloxrouteMaxProfitBox = createParameterizedCheckbox(&configPage.masterConfig.MevBoost.BloxRouteMaxProfitRelay) diff --git a/rocketpool-cli/service/config/settings-native-fallback.go b/rocketpool-cli/service/config/settings-native-fallback.go index 9aecba0df..d414a0888 100644 --- a/rocketpool-cli/service/config/settings-native-fallback.go +++ b/rocketpool-cli/service/config/settings-native-fallback.go @@ -54,7 +54,7 @@ func (configPage *NativeFallbackConfigPage) createContent() { // Set up the form items configPage.useFallbackBox = createParameterizedCheckbox(&configPage.masterConfig.UseFallbackClients) configPage.reconnectDelay = createParameterizedStringField(&configPage.masterConfig.ReconnectDelay) - configPage.fallbackItems = createParameterizedFormItems(configPage.masterConfig.FallbackNormal.GetParameters(), configPage.layout.descriptionBox) + configPage.fallbackItems = createParameterizedFormItems(configPage.masterConfig.FallbackNormal.GetParameters(), configPage.layout) // Map the parameters to the form items in the layout configPage.layout.mapParameterizedFormItems(configPage.useFallbackBox, configPage.reconnectDelay) diff --git a/rocketpool-cli/service/config/settings-native-smartnode.go b/rocketpool-cli/service/config/settings-native-smartnode.go index 184d385ef..af05f8cc1 100644 --- a/rocketpool-cli/service/config/settings-native-smartnode.go +++ b/rocketpool-cli/service/config/settings-native-smartnode.go @@ -44,7 +44,7 @@ func (configPage *NativeSmartnodeConfigPage) createContent() { // Set up the form items params := append(masterConfig.Smartnode.GetParameters(), &masterConfig.EnableIPv6, &masterConfig.Alertmanager.ShowAlertsOnCLI) - formItems := createParameterizedFormItems(params, layout.descriptionBox) + formItems := createParameterizedFormItems(params, layout) for _, formItem := range formItems { if formItem.parameter.ID == config.ProjectNameID { // Ignore the project name ID since it doesn't apply to native mode diff --git a/rocketpool-cli/service/config/settings-native.go b/rocketpool-cli/service/config/settings-native.go index 6e73095f3..c30aaefba 100644 --- a/rocketpool-cli/service/config/settings-native.go +++ b/rocketpool-cli/service/config/settings-native.go @@ -43,7 +43,7 @@ func (configPage *NativePage) createContent() { configPage.layout.setupEscapeReturnHomeHandler(configPage.home.md, configPage.home.homePage) // Set up the form items - configPage.nativeItems = createParameterizedFormItems(configPage.masterConfig.Native.GetParameters(), configPage.layout.descriptionBox) + configPage.nativeItems = createParameterizedFormItems(configPage.masterConfig.Native.GetParameters(), configPage.layout) // Map the parameters to the form items in the layout configPage.layout.mapParameterizedFormItems(configPage.nativeItems...) diff --git a/rocketpool-cli/service/config/settings-smartnode.go b/rocketpool-cli/service/config/settings-smartnode.go index 131df5bcb..7c9545d0a 100644 --- a/rocketpool-cli/service/config/settings-smartnode.go +++ b/rocketpool-cli/service/config/settings-smartnode.go @@ -51,7 +51,7 @@ func (configPage *SmartnodeConfigPage) createContent() { // Set up the form items params := append(masterConfig.Smartnode.GetParameters(), &masterConfig.EnableIPv6, &masterConfig.Alertmanager.ShowAlertsOnCLI) - formItems := createParameterizedFormItems(params, layout.descriptionBox) + formItems := createParameterizedFormItems(params, layout) for _, formItem := range formItems { layout.form.AddFormItem(formItem.item) layout.parameters[formItem.item] = formItem diff --git a/rocketpool-cli/service/config/standard-layout.go b/rocketpool-cli/service/config/standard-layout.go index bc870b34e..e7fae17b2 100644 --- a/rocketpool-cli/service/config/standard-layout.go +++ b/rocketpool-cli/service/config/standard-layout.go @@ -12,12 +12,14 @@ import ( // A layout container with the standard elements and design type standardLayout struct { - grid *tview.Grid - content tview.Primitive - descriptionBox *tview.TextView - footer tview.Primitive - form *Form - parameters map[tview.FormItem]*parameterizedFormItem + grid *tview.Grid + content tview.Primitive + descriptionBox *tview.TextView + errorAlert *tview.TextView + footer tview.Primitive + form *Form + parameters map[tview.FormItem]*parameterizedFormItem + commitErrorActive bool } // Creates a new StandardLayout instance, which includes the grid and description box preconstructed. @@ -100,6 +102,9 @@ func (layout *standardLayout) createForm(networkParam *cfgtypes.Parameter, title }) layout.form = form + layout.form.SetNavigationBlocked(func() bool { + return layout.commitErrorActive + }) layout.setContent(form, form.Box, title) layout.createSettingFooter() } @@ -133,9 +138,73 @@ func (layout *standardLayout) refresh() { } +// Shows an error alert at the bottom of the screen, or clears it when message is empty. +func (layout *standardLayout) showCommitError(message string) { + layout.commitErrorActive = message != "" + if layout.errorAlert == nil { + return + } + if message == "" { + layout.errorAlert.SetText("") + return + } + layout.errorAlert.SetText("[red::b]" + message + "[-:-:-]") +} + +func (layout *standardLayout) commitFocusedInputField() { + for _, param := range layout.parameters { + if _, ok := param.item.(*tview.InputField); ok && param.item.HasFocus() { + param.commit() + return + } + } +} + +func (layout *standardLayout) navCapture(event *tcell.EventKey) *tcell.EventKey { + if layout.commitErrorActive { + switch event.Key() { + case tcell.KeyDown, tcell.KeyTab: + layout.commitFocusedInputField() + if layout.commitErrorActive { + return nil + } + return tcell.NewEventKey(tcell.KeyTab, 0, 0) + case tcell.KeyUp, tcell.KeyBacktab: + return nil + } + return event + } + switch event.Key() { + case tcell.KeyDown, tcell.KeyTab: + return tcell.NewEventKey(tcell.KeyTab, 0, 0) + case tcell.KeyUp, tcell.KeyBacktab: + return tcell.NewEventKey(tcell.KeyBacktab, 0, 0) + } + return event +} + +func (layout *standardLayout) setItemNavCapture(item *parameterizedFormItem) { + capture := layout.navCapture + switch el := item.item.(type) { + case *tview.Checkbox: + el.SetInputCapture(capture) + case *tview.InputField: + el.SetInputCapture(capture) + case *DropDown: + el.SetInputCapture(capture) + } +} + // Create the footer, including the nav bar func (layout *standardLayout) createSettingFooter() { + layout.errorAlert = tview.NewTextView(). + SetDynamicColors(true). + SetTextAlign(tview.AlignCenter). + SetWrap(false). + SetRegions(false) + layout.errorAlert.SetBackgroundColor(tview.Styles.ContrastBackgroundColor) + // Nav bar navString1 := "Arrow keys: Navigate Space/Enter: Change Setting" navTextView1 := tview.NewTextView(). @@ -170,7 +239,12 @@ func (layout *standardLayout) createSettingFooter() { AddItem(tview.NewBox(), 0, 1, false), 1, 1, false) - layout.setFooter(navBar, 2) + footer := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(layout.errorAlert, 1, 0, false). + AddItem(navBar, 2, 1, false) + + layout.setFooter(footer, 3) } @@ -206,6 +280,10 @@ func (layout *standardLayout) addFormItemsWithCommonParams(commonParams []*param func (layout *standardLayout) mapParameterizedFormItems(params ...*parameterizedFormItem) { for _, param := range params { + if layout != nil { + param.onCommitError = layout.showCommitError + layout.setItemNavCapture(param) + } layout.parameters[param.item] = param } } @@ -229,12 +307,15 @@ func (layout *standardLayout) getInputCapture(md *MainDisplay, prev *page) func( // Save the text if this field is one if _, ok := param.item.(*tview.InputField); ok { - param.commit() - // Exit the loop to return to the home page + layout.commitFocusedInputField() break } } + if layout.commitErrorActive { + return nil + } + md.setPage(prev) return nil }