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