diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index d47ec83..451a76d 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -342,8 +342,8 @@ func TestAuthMethodListViewShowsAllOptions(t *testing.T) { if between := view[authPos:listPos]; strings.Contains(between, "Identity File") { t.Fatalf("expected auth method list to render directly under auth field\nview:\n%s", view) } - if strings.Contains(view, "│") { - t.Fatalf("expected compact auth method dropdown without default list border\nview:\n%s", view) + if !strings.Contains(view, "│") { + t.Fatalf("expected auth method dropdown inside the unified frame\nview:\n%s", view) } for _, method := range []model.AuthMethod{ model.AuthPassword, @@ -383,8 +383,8 @@ func TestGroupListViewRendersDirectlyUnderGroupField(t *testing.T) { if between := view[groupPos:listPos]; strings.Contains(between, "Password") { t.Fatalf("expected group dropdown to render before password field\nview:\n%s", view) } - if strings.Contains(view, "│") { - t.Fatalf("expected compact group dropdown without default list border\nview:\n%s", view) + if !strings.Contains(view, "│") { + t.Fatalf("expected group dropdown inside the unified frame\nview:\n%s", view) } } diff --git a/internal/tui/form.go b/internal/tui/form.go index 27587dd..2147aa2 100644 --- a/internal/tui/form.go +++ b/internal/tui/form.go @@ -589,14 +589,6 @@ func (fm *formModel) View() string { if fm.edit { title = "Edit Server: " + fm.server.Alias } - footer := renderHelp([]helpItem{ - {Key: "Tab/↓", Action: "next"}, - {Key: "↑", Action: "prev"}, - {Key: "/", Action: "pick list"}, - {Key: "Enter", Action: "select"}, - {Key: "Esc", Action: "back"}, - }, fm.width) - if fm.showAuthList || fm.showGroupList { var dropdown list.Model fieldIndex := 8 @@ -606,10 +598,18 @@ func (fm *formModel) View() string { } else { dropdown = fm.groupList } - return titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width)) + "\n" + - fitLine(fm.inputs[fieldIndex].View(), fm.width) + "\n" + - fitLine(renderDropdown(dropdown), fm.width) + "\n" + - renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width) + return renderScreenShell(screenShell{ + breadcrumb: title + " / Picker", + status: "Choose a value", + width: fm.width, + height: fm.height, + body: func(width, height int) string { + lines := []string{fm.inputs[fieldIndex].View(), ""} + lines = append(lines, splitBlock(renderDropdown(dropdown))...) + return renderPaddedPanel(width, height, lines) + }, + footer: []helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}}, + }) } status := fm.formStatusLine() @@ -620,60 +620,68 @@ func (fm *formModel) View() string { if fm.focusIdx == len(fm.inputs)+2 { saveBtn = selectedStyle.Render("> [ Save ]") } - actions := fitLine(testBtn+" "+saveBtn, fm.width) + actions := testBtn + " " + saveBtn - reserved := 1 + displayLineCount(footer) + 1 - if status != "" { - reserved++ - } - fieldRows := max(4, fm.height-reserved) - richLayout := fm.width >= 90 && fm.height >= 24 - allFields := make([]string, 0, len(fm.inputs)+5) - focusRows := make([]int, len(fm.inputs)+1) - for i := range fm.inputs { - if richLayout { - if section := formSectionTitle(i); section != "" { - allFields = append(allFields, sectionStyle.Copy().MarginTop(0).Render(section)) + body := func(width, height int) string { + richLayout := width >= 90 && height >= 20 + allFields := make([]string, 0, len(fm.inputs)+5) + focusRows := make([]int, len(fm.inputs)+1) + for i := range fm.inputs { + if richLayout { + if section := formSectionTitle(i); section != "" { + allFields = append(allFields, sectionStyle.Copy().MarginTop(0).Render(section)) + } } + if i == 5 { + fm.inputs[i].Placeholder = "password/key/key_passphrase/agent" + } + if i == 8 && len(fm.groups) > 0 { + fm.inputs[i].Placeholder = truncateCells(strings.Join(fm.groups, ", "), 25) + } + focusRows[i] = len(allFields) + allFields = append(allFields, fm.inputs[i].View()) } - if i == 5 { - fm.inputs[i].Placeholder = "password/key/key_passphrase/agent" + focusRows[len(fm.inputs)] = len(allFields) + allFields = append(allFields, fm.password.View()) + focusField := len(allFields) - 1 + if fm.focusIdx <= len(fm.inputs) { + focusField = focusRows[fm.focusIdx] } - if i == 8 && len(fm.groups) > 0 { - fm.inputs[i].Placeholder = truncateCells(strings.Join(fm.groups, ", "), 25) + actionRows := 1 + if richLayout { + actionRows = 2 } - focusRows[i] = len(allFields) - allFields = append(allFields, fitLine(fm.inputs[i].View(), fm.width)) + fieldRows := max(1, height-2-actionRows) + start, end := visibleServerRange(len(allFields), focusField, fieldRows) + visible := append([]string(nil), allFields[start:end]...) + if start > 0 && len(visible) > 0 { + visible[0] = "↑ more fields · " + visible[0] + } + if end < len(allFields) && len(visible) > 0 { + visible[len(visible)-1] += " · more ↓" + } + if richLayout { + visible = append(visible, sectionStyle.Copy().MarginTop(0).Render("Actions")) + } + visible = append(visible, actions) + return renderPaddedPanel(width, height, visible) } - focusRows[len(fm.inputs)] = len(allFields) - allFields = append(allFields, fitLine(fm.password.View(), fm.width)) - focusField := len(allFields) - 1 - if fm.focusIdx <= len(fm.inputs) { - focusField = focusRows[fm.focusIdx] - } - start, end := visibleServerRange(len(allFields), focusField, fieldRows) - visible := append([]string(nil), allFields[start:end]...) - if start > 0 && len(visible) > 0 { - visible[0] = fitLine("↑ more fields · "+visible[0], fm.width) - } - if end < len(allFields) && len(visible) > 0 { - visible[len(visible)-1] = fitLine(visible[len(visible)-1]+" · more ↓", fm.width) - } - - lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))} - lines = append(lines, visible...) - if status != "" { - lines = append(lines, fitLine(status, fm.width)) - } - if richLayout { - lines = append(lines, sectionStyle.Copy().MarginTop(0).Render("Actions")) - } - lines = append(lines, actions) - lines = append(lines, strings.Split(footer, "\n")...) - if len(lines) > fm.height && fm.height > 0 { - lines = lines[:fm.height] - } - return strings.Join(lines, "\n") + return renderScreenShell(screenShell{ + breadcrumb: title, + status: "Server profile", + notification: status, + width: fm.width, + height: fm.height, + body: body, + footer: []helpItem{ + {Key: "Tab/↓", Action: "next"}, + {Key: "↑", Action: "prev"}, + {Key: "/", Action: "pick list"}, + {Key: "Enter", Action: "select"}, + {Key: "Ctrl+H", Action: "help"}, + {Key: "Esc", Action: "back"}, + }, + }) } func (fm *formModel) formStatusLine() string { @@ -701,7 +709,7 @@ func (fm *formModel) formStatusLine() string { func renderDropdown(l list.Model) string { var b strings.Builder - b.WriteString(sectionStyle.Render(l.Title)) + b.WriteString(dashboardSection(l.Title)) b.WriteString("\n") for i, item := range l.Items() { group, ok := item.(groupItem) diff --git a/internal/tui/layout_test.go b/internal/tui/layout_test.go index 500e090..dd7d52e 100644 --- a/internal/tui/layout_test.go +++ b/internal/tui/layout_test.go @@ -90,6 +90,7 @@ func TestServerFormFitsSupportedTerminalSizes(t *testing.T) { fm.updateFocus() view := fm.View() assertViewFits(t, view, size.width, size.height) + assertUnifiedScreen(t, view, size.width, size.height) for _, want := range []string{"Server", "Port *", "not-a-port", "Port must be", "Save", "Esc"} { if !strings.Contains(view, want) { t.Fatalf("form at %dx%d missing %q:\n%s", size.width, size.height, want, view) @@ -224,6 +225,7 @@ func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) { form.inputs[1].SetValue("printf 'a very long command that remains editable'") view := form.View() assertViewFits(t, view, size.width, size.height) + assertUnifiedScreen(t, view, size.width, size.height) for _, want := range []string{"Template", "Name *", "Save", "Esc"} { if !strings.Contains(view, want) { t.Fatalf("template form at %dx%d missing %q:\n%s", size.width, size.height, want, view) @@ -232,6 +234,21 @@ func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) { } } +func TestServerFormDropdownUsesUnifiedShell(t *testing.T) { + for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} { + form := newFormModel(size.width, size.height) + form.focusIdx = 5 + form.showAuthList = true + view := form.View() + assertUnifiedScreen(t, view, size.width, size.height) + for _, want := range []string{"Select auth method", "password", "agent", "Enter", "Esc"} { + if !strings.Contains(view, want) { + t.Fatalf("dropdown at %dx%d missing %q:\n%s", size.width, size.height, want, view) + } + } + } +} + func assertViewFits(t *testing.T, view string, width, height int) { t.Helper() lines := strings.Split(strings.TrimRight(view, "\n"), "\n") diff --git a/internal/tui/template_form.go b/internal/tui/template_form.go index 3110206..21ea4ca 100644 --- a/internal/tui/template_form.go +++ b/internal/tui/template_form.go @@ -151,31 +151,40 @@ func (tf *templateFormModel) save() tea.Cmd { } func (tf *templateFormModel) View() string { - var b strings.Builder title := "Add Template" if tf.edit { title = "Edit Template" } - b.WriteString(titleStyle.Copy().MarginLeft(0).Render(fitLine(title, tf.width))) - b.WriteString("\n\n") - for i := range tf.inputs { - b.WriteString(fitLine(tf.inputs[i].View(), tf.width)) - b.WriteString("\n") - } - button := " [ Save ]" - if tf.focusIdx == len(tf.inputs) { - button = selectedStyle.Render("> [ Save ]") - } - b.WriteString("\n" + button + "\n\n") + notification := "" if tf.err != nil { - b.WriteString(errorStyle.Render(tf.err.Error())) - b.WriteString("\n") + notification = errorStyle.Render(tf.err.Error()) + } else if tf.saved { + notification = successStyle.Render("✓ Saved.") } - b.WriteString(renderHelp([]helpItem{ - {Key: "Tab/↓", Action: "next"}, - {Key: "↑", Action: "prev"}, - {Key: "Enter", Action: "select"}, - {Key: "Esc", Action: "back"}, - }, tf.width)) - return b.String() + return renderScreenShell(screenShell{ + breadcrumb: "Command Templates / " + title, + status: "Template editor", + notification: notification, + width: tf.width, + height: tf.height, + body: func(width, height int) string { + lines := make([]string, 0, len(tf.inputs)+3) + for i := range tf.inputs { + lines = append(lines, tf.inputs[i].View()) + } + button := " [ Save ]" + if tf.focusIdx == len(tf.inputs) { + button = selectedStyle.Render("> [ Save ]") + } + lines = append(lines, "", button) + return renderPaddedPanel(width, height, lines) + }, + footer: []helpItem{ + {Key: "Tab/↓", Action: "next"}, + {Key: "↑", Action: "prev"}, + {Key: "Enter", Action: "select"}, + {Key: "Ctrl+H", Action: "help"}, + {Key: "Esc", Action: "back"}, + }, + }) }