fix(tui): preserve floating dialog layout
- commit
- b3989ce2e8a80732a8e37b40e10d82853e5194b6
- author
- brice · · 2026-08-14 08:56 UTC
- parent
- 9e66d9a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +137 -35
M packages/agent/modes/interactive_rendering.go +31 -3
| @@ -561,8 +561,11 @@ func (i *Interactive) redraw() { | |||
| 561 | 561 | i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5) | |
| 562 | 562 | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth) | |
| 563 | 563 | } | |
| 564 | + | dialogTitle := "" | |
| 565 | + | dialogRemovedTopRows := 0 | |
| 564 | 566 | if len(dialog) > 0 { | |
| 565 | 567 | dialog = padDialogFrame(dialog) | |
| 568 | + | dialogTitle, dialog, dialogRemovedTopRows = floatingDialogBody(dialog) | |
| 566 | 569 | } | |
| 567 | 570 | dialogFocusRow := -1 | |
| 568 | 571 | for row, line := range dialog { | |
| @@ -1024,7 +1027,10 @@ func (i *Interactive) redraw() { | |||
| 1024 | 1027 | } | |
| 1025 | 1028 | dialogCursorRow, dialogCursorCol := dialogFocusRow, 0 | |
| 1026 | 1029 | if cursorInDialog { | |
| 1027 | - | dialogCursorRow, dialogCursorCol = cursorRow, cursorCol | |
| 1030 | + | dialogCursorRow, dialogCursorCol = cursorRow-dialogRemovedTopRows, cursorCol | |
| 1031 | + | if dialogCursorRow < 0 { | |
| 1032 | + | dialogCursorRow = -1 | |
| 1033 | + | } | |
| 1028 | 1034 | } | |
| 1029 | 1035 | i.setInputCursorDimmed(modalBackdrop && !cursorInDialog) | |
| 1030 | 1036 | if i.renderRevision.Load() != renderRevision { | |
| @@ -1041,7 +1047,7 @@ func (i *Interactive) redraw() { | |||
| 1041 | 1047 | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | |
| 1042 | 1048 | background = floatingRightBarFrame(theme, background, rightBar, mainCols, rightBarWidth) | |
| 1043 | 1049 | } | |
| 1044 | - | pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol) | |
| 1050 | + | pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, dialogTitle, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol) | |
| 1045 | 1051 | i.rend.DrawFloating(pane, paneCursorRow, paneCursorCol) | |
| 1046 | 1052 | } else if rightBarActive { | |
| 1047 | 1053 | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | |
| @@ -1059,6 +1065,25 @@ func (i *Interactive) redraw() { | |||
| 1059 | 1065 | i.mu.Unlock() | |
| 1060 | 1066 | } | |
| 1061 | 1067 | ||
| 1068 | + | // floatingDialogBody removes a legacy dialog's rule chrome and returns its | |
| 1069 | + | // title for the shared floating-pane border. | |
| 1070 | + | func floatingDialogBody(lines []string) (title string, body []string, removedTopRows int) { | |
| 1071 | + | if len(lines) == 0 { | |
| 1072 | + | return "", nil, 0 | |
| 1073 | + | } | |
| 1074 | + | body = lines | |
| 1075 | + | if isFrameHeaderLine(body[0]) { | |
| 1076 | + | plain := strings.TrimPrefix(stripANSIBytes(body[0]), "── ") | |
| 1077 | + | title = strings.TrimSpace(strings.TrimRight(plain, "─")) | |
| 1078 | + | body = body[1:] | |
| 1079 | + | removedTopRows = 1 | |
| 1080 | + | } | |
| 1081 | + | if last := len(body) - 1; last >= 0 && isFrameRuleLine(body[last]) { | |
| 1082 | + | body = body[:last] | |
| 1083 | + | } | |
| 1084 | + | return title, body, removedTopRows | |
| 1085 | + | } | |
| 1086 | + | ||
| 1062 | 1087 | // floatingBackgroundFrame preserves the regular viewport layout while an | |
| 1063 | 1088 | // independent view is open. It is a fixed terminal frame only for the overlay | |
| 1064 | 1089 | // lifetime; normal rendering immediately returns to scrollback-oriented flow | |
| @@ -1078,7 +1103,10 @@ func floatingBackgroundFrame(chat, bottom []string, rows int) []string { | |||
| 1078 | 1103 | lines = lines[len(lines)-rows:] | |
| 1079 | 1104 | } | |
| 1080 | 1105 | frame := make([]string, rows) | |
| 1081 | - | copy(frame[rows-len(lines):], lines) | |
| 1106 | + | // DrawLog's flow renderer leaves the logical session content at the | |
| 1107 | + | // viewport's top. Keep that anchoring when switching to a fixed floating | |
| 1108 | + | // frame so opening a pane does not visibly jump the whole session down. | |
| 1109 | + | copy(frame, lines) | |
| 1082 | 1110 | return frame | |
| 1083 | 1111 | } | |
| 1084 | 1112 | ||
| @@ -561,8 +561,11 @@ func (i *Interactive) redraw() { | |||||
| 561 | i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5) | 561 | i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5) | ||
| 562 | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth) | 562 | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth) | ||
| 563 | } | 563 | } | ||
| 564 | + | dialogTitle := "" | |||
| 565 | + | dialogRemovedTopRows := 0 | |||
| 564 | if len(dialog) > 0 { | 566 | if len(dialog) > 0 { | ||
| 565 | dialog = padDialogFrame(dialog) | 567 | dialog = padDialogFrame(dialog) | ||
| 568 | + | dialogTitle, dialog, dialogRemovedTopRows = floatingDialogBody(dialog) | |||
| 566 | } | 569 | } | ||
| 567 | dialogFocusRow := -1 | 570 | dialogFocusRow := -1 | ||
| 568 | for row, line := range dialog { | 571 | for row, line := range dialog { | ||
| @@ -1024,7 +1027,10 @@ func (i *Interactive) redraw() { | |||||
| 1024 | } | 1027 | } | ||
| 1025 | dialogCursorRow, dialogCursorCol := dialogFocusRow, 0 | 1028 | dialogCursorRow, dialogCursorCol := dialogFocusRow, 0 | ||
| 1026 | if cursorInDialog { | 1029 | if cursorInDialog { | ||
| 1027 | - | dialogCursorRow, dialogCursorCol = cursorRow, cursorCol | 1030 | + | dialogCursorRow, dialogCursorCol = cursorRow-dialogRemovedTopRows, cursorCol |
| 1031 | + | if dialogCursorRow < 0 { | |||
| 1032 | + | dialogCursorRow = -1 | |||
| 1033 | + | } | |||
| 1028 | } | 1034 | } | ||
| 1029 | i.setInputCursorDimmed(modalBackdrop && !cursorInDialog) | 1035 | i.setInputCursorDimmed(modalBackdrop && !cursorInDialog) | ||
| 1030 | if i.renderRevision.Load() != renderRevision { | 1036 | if i.renderRevision.Load() != renderRevision { | ||
| @@ -1041,7 +1047,7 @@ func (i *Interactive) redraw() { | |||||
| 1041 | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | 1047 | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | ||
| 1042 | background = floatingRightBarFrame(theme, background, rightBar, mainCols, rightBarWidth) | 1048 | background = floatingRightBarFrame(theme, background, rightBar, mainCols, rightBarWidth) | ||
| 1043 | } | 1049 | } | ||
| 1044 | - | pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol) | 1050 | + | pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, dialogTitle, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol) |
| 1045 | i.rend.DrawFloating(pane, paneCursorRow, paneCursorCol) | 1051 | i.rend.DrawFloating(pane, paneCursorRow, paneCursorCol) | ||
| 1046 | } else if rightBarActive { | 1052 | } else if rightBarActive { | ||
| 1047 | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | 1053 | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | ||
| @@ -1059,6 +1065,25 @@ func (i *Interactive) redraw() { | |||||
| 1059 | i.mu.Unlock() | 1065 | i.mu.Unlock() | ||
| 1060 | } | 1066 | } | ||
| 1061 | 1067 | ||||
| 1068 | + | // floatingDialogBody removes a legacy dialog's rule chrome and returns its | |||
| 1069 | + | // title for the shared floating-pane border. | |||
| 1070 | + | func floatingDialogBody(lines []string) (title string, body []string, removedTopRows int) { | |||
| 1071 | + | if len(lines) == 0 { | |||
| 1072 | + | return "", nil, 0 | |||
| 1073 | + | } | |||
| 1074 | + | body = lines | |||
| 1075 | + | if isFrameHeaderLine(body[0]) { | |||
| 1076 | + | plain := strings.TrimPrefix(stripANSIBytes(body[0]), "── ") | |||
| 1077 | + | title = strings.TrimSpace(strings.TrimRight(plain, "─")) | |||
| 1078 | + | body = body[1:] | |||
| 1079 | + | removedTopRows = 1 | |||
| 1080 | + | } | |||
| 1081 | + | if last := len(body) - 1; last >= 0 && isFrameRuleLine(body[last]) { | |||
| 1082 | + | body = body[:last] | |||
| 1083 | + | } | |||
| 1084 | + | return title, body, removedTopRows | |||
| 1085 | + | } | |||
| 1086 | + | ||||
| 1062 | // floatingBackgroundFrame preserves the regular viewport layout while an | 1087 | // floatingBackgroundFrame preserves the regular viewport layout while an | ||
| 1063 | // independent view is open. It is a fixed terminal frame only for the overlay | 1088 | // independent view is open. It is a fixed terminal frame only for the overlay | ||
| 1064 | // lifetime; normal rendering immediately returns to scrollback-oriented flow | 1089 | // lifetime; normal rendering immediately returns to scrollback-oriented flow | ||
| @@ -1078,7 +1103,10 @@ func floatingBackgroundFrame(chat, bottom []string, rows int) []string { | |||||
| 1078 | lines = lines[len(lines)-rows:] | 1103 | lines = lines[len(lines)-rows:] | ||
| 1079 | } | 1104 | } | ||
| 1080 | frame := make([]string, rows) | 1105 | frame := make([]string, rows) | ||
| 1081 | - | copy(frame[rows-len(lines):], lines) | 1106 | + | // DrawLog's flow renderer leaves the logical session content at the |
| 1107 | + | // viewport's top. Keep that anchoring when switching to a fixed floating | |||
| 1108 | + | // frame so opening a pane does not visibly jump the whole session down. | |||
| 1109 | + | copy(frame, lines) | |||
| 1082 | return frame | 1110 | return frame | ||
| 1083 | } | 1111 | } | ||
| 1084 | 1112 | ||||
M packages/agent/modes/modal_backdrop_test.go +51 -12
| @@ -7,11 +7,47 @@ import ( | |||
| 7 | 7 | "github.com/bnema/zut/packages/tui" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | - | func TestFloatingBackgroundFrameKeepsNewestOversizedBottomRows(t *testing.T) { | |
| 11 | - | got := floatingBackgroundFrame([]string{"chat"}, []string{"old", "newer", "newest"}, 3) | |
| 12 | - | want := []string{"newer", "newest", ""} | |
| 13 | - | if strings.Join(got, "\n") != strings.Join(want, "\n") { | |
| 14 | - | t.Fatalf("floating background = %q, want %q", got, want) | |
| 10 | + | func TestFloatingDialogBodyMovesFrameTitleIntoPaneBorder(t *testing.T) { | |
| 11 | + | lines := padDialogFrame([]string{frameHeader(tui.Dark, "settings", 40), "body", frameRule(tui.Dark, 40)}) | |
| 12 | + | title, body, removed := floatingDialogBody(lines) | |
| 13 | + | if title != "settings" || removed != 1 { | |
| 14 | + | t.Fatalf("chrome extraction = title:%q removed:%d, want settings/1", title, removed) | |
| 15 | + | } | |
| 16 | + | if strings.Join(body, "\n") != "\nbody\n" { | |
| 17 | + | t.Fatalf("dialog body retained frame chrome: %q", body) | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestFloatingBackgroundFramePreservesTopAnchoredViewport(t *testing.T) { | |
| 22 | + | tests := []struct { | |
| 23 | + | name string | |
| 24 | + | chat []string | |
| 25 | + | bottom []string | |
| 26 | + | rows int | |
| 27 | + | want []string | |
| 28 | + | }{ | |
| 29 | + | { | |
| 30 | + | name: "session stays at top", | |
| 31 | + | chat: []string{"chat"}, | |
| 32 | + | bottom: []string{"status"}, | |
| 33 | + | rows: 5, | |
| 34 | + | want: []string{"chat", "status", "", "", ""}, | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | name: "oversized bottom retains newest rows", | |
| 38 | + | chat: []string{"chat"}, | |
| 39 | + | bottom: []string{"old", "newer", "newest"}, | |
| 40 | + | rows: 3, | |
| 41 | + | want: []string{"newer", "newest", ""}, | |
| 42 | + | }, | |
| 43 | + | } | |
| 44 | + | for _, tt := range tests { | |
| 45 | + | t.Run(tt.name, func(t *testing.T) { | |
| 46 | + | got := floatingBackgroundFrame(tt.chat, tt.bottom, tt.rows) | |
| 47 | + | if strings.Join(got, "\n") != strings.Join(tt.want, "\n") { | |
| 48 | + | t.Fatalf("floating background = %q, want %q", got, tt.want) | |
| 49 | + | } | |
| 50 | + | }) | |
| 15 | 51 | } | |
| 16 | 52 | } | |
| 17 | 53 | ||
| @@ -32,15 +68,18 @@ func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) { | |||
| 32 | 68 | t.Fatalf("settings dialog left the main input undimmed: %q", got) | |
| 33 | 69 | } | |
| 34 | 70 | pane := tui.FloatingPaneMaxRect(80, 24) | |
| 35 | - | header := frameHeader(i.cfg.Theme, "settings", pane.ContentWidth()) | |
| 36 | - | if !strings.Contains(got, header) { | |
| 37 | - | t.Fatalf("settings dialog was not rendered inside the floating pane: %q", got) | |
| 71 | + | innerHeader := frameHeader(i.cfg.Theme, "settings", pane.ContentWidth()) | |
| 72 | + | if strings.Contains(got, innerHeader) { | |
| 73 | + | t.Fatalf("settings retained its inner frame header: %q", got) | |
| 74 | + | } | |
| 75 | + | if !strings.Contains(got, " settings ") { | |
| 76 | + | t.Fatalf("settings title was not rendered in the floating border: %q", got) | |
| 38 | 77 | } | |
| 39 | - | if strings.Contains(got, tui.Dim(header)) { | |
| 40 | - | t.Fatalf("settings dialog itself was dimmed: %q", got) | |
| 78 | + | if strings.Contains(got, tui.Dim(" settings ")) { | |
| 79 | + | t.Fatalf("floating pane title was dimmed: %q", got) | |
| 41 | 80 | } | |
| 42 | - | // The pane shrinks to its content and remains horizontally centered. | |
| 43 | - | if !strings.Contains(got, tui.MoveTo(9, pane.X+1)) { | |
| 81 | + | // The pane shrinks to its chrome-free content and remains centered. | |
| 82 | + | if !strings.Contains(got, tui.MoveTo(10, pane.X+1)) { | |
| 44 | 83 | t.Fatalf("floating pane was not positioned at its computed geometry: %q", got) | |
| 45 | 84 | } | |
| 46 | 85 | cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent)) | |
| @@ -7,11 +7,47 @@ import ( | |||||
| 7 | "github.com/bnema/zut/packages/tui" | 7 | "github.com/bnema/zut/packages/tui" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | - | func TestFloatingBackgroundFrameKeepsNewestOversizedBottomRows(t *testing.T) { | 10 | + | func TestFloatingDialogBodyMovesFrameTitleIntoPaneBorder(t *testing.T) { |
| 11 | - | got := floatingBackgroundFrame([]string{"chat"}, []string{"old", "newer", "newest"}, 3) | 11 | + | lines := padDialogFrame([]string{frameHeader(tui.Dark, "settings", 40), "body", frameRule(tui.Dark, 40)}) |
| 12 | - | want := []string{"newer", "newest", ""} | 12 | + | title, body, removed := floatingDialogBody(lines) |
| 13 | - | if strings.Join(got, "\n") != strings.Join(want, "\n") { | 13 | + | if title != "settings" || removed != 1 { |
| 14 | - | t.Fatalf("floating background = %q, want %q", got, want) | 14 | + | t.Fatalf("chrome extraction = title:%q removed:%d, want settings/1", title, removed) |
| 15 | + | } | |||
| 16 | + | if strings.Join(body, "\n") != "\nbody\n" { | |||
| 17 | + | t.Fatalf("dialog body retained frame chrome: %q", body) | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func TestFloatingBackgroundFramePreservesTopAnchoredViewport(t *testing.T) { | |||
| 22 | + | tests := []struct { | |||
| 23 | + | name string | |||
| 24 | + | chat []string | |||
| 25 | + | bottom []string | |||
| 26 | + | rows int | |||
| 27 | + | want []string | |||
| 28 | + | }{ | |||
| 29 | + | { | |||
| 30 | + | name: "session stays at top", | |||
| 31 | + | chat: []string{"chat"}, | |||
| 32 | + | bottom: []string{"status"}, | |||
| 33 | + | rows: 5, | |||
| 34 | + | want: []string{"chat", "status", "", "", ""}, | |||
| 35 | + | }, | |||
| 36 | + | { | |||
| 37 | + | name: "oversized bottom retains newest rows", | |||
| 38 | + | chat: []string{"chat"}, | |||
| 39 | + | bottom: []string{"old", "newer", "newest"}, | |||
| 40 | + | rows: 3, | |||
| 41 | + | want: []string{"newer", "newest", ""}, | |||
| 42 | + | }, | |||
| 43 | + | } | |||
| 44 | + | for _, tt := range tests { | |||
| 45 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 46 | + | got := floatingBackgroundFrame(tt.chat, tt.bottom, tt.rows) | |||
| 47 | + | if strings.Join(got, "\n") != strings.Join(tt.want, "\n") { | |||
| 48 | + | t.Fatalf("floating background = %q, want %q", got, tt.want) | |||
| 49 | + | } | |||
| 50 | + | }) | |||
| 15 | } | 51 | } | ||
| 16 | } | 52 | } | ||
| 17 | 53 | ||||
| @@ -32,15 +68,18 @@ func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) { | |||||
| 32 | t.Fatalf("settings dialog left the main input undimmed: %q", got) | 68 | t.Fatalf("settings dialog left the main input undimmed: %q", got) | ||
| 33 | } | 69 | } | ||
| 34 | pane := tui.FloatingPaneMaxRect(80, 24) | 70 | pane := tui.FloatingPaneMaxRect(80, 24) | ||
| 35 | - | header := frameHeader(i.cfg.Theme, "settings", pane.ContentWidth()) | 71 | + | innerHeader := frameHeader(i.cfg.Theme, "settings", pane.ContentWidth()) |
| 36 | - | if !strings.Contains(got, header) { | 72 | + | if strings.Contains(got, innerHeader) { |
| 37 | - | t.Fatalf("settings dialog was not rendered inside the floating pane: %q", got) | 73 | + | t.Fatalf("settings retained its inner frame header: %q", got) |
| 74 | + | } | |||
| 75 | + | if !strings.Contains(got, " settings ") { | |||
| 76 | + | t.Fatalf("settings title was not rendered in the floating border: %q", got) | |||
| 38 | } | 77 | } | ||
| 39 | - | if strings.Contains(got, tui.Dim(header)) { | 78 | + | if strings.Contains(got, tui.Dim(" settings ")) { |
| 40 | - | t.Fatalf("settings dialog itself was dimmed: %q", got) | 79 | + | t.Fatalf("floating pane title was dimmed: %q", got) |
| 41 | } | 80 | } | ||
| 42 | - | // The pane shrinks to its content and remains horizontally centered. | 81 | + | // The pane shrinks to its chrome-free content and remains centered. |
| 43 | - | if !strings.Contains(got, tui.MoveTo(9, pane.X+1)) { | 82 | + | if !strings.Contains(got, tui.MoveTo(10, pane.X+1)) { |
| 44 | t.Fatalf("floating pane was not positioned at its computed geometry: %q", got) | 83 | t.Fatalf("floating pane was not positioned at its computed geometry: %q", got) | ||
| 45 | } | 84 | } | ||
| 46 | cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent)) | 85 | cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent)) | ||
M packages/tui/floating_pane.go +36 -9
| @@ -101,11 +101,11 @@ type FloatingPaneFrame struct { | |||
| 101 | 101 | Rect FloatingPaneRect | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | - | // Compose returns a complete floating-pane frame. contentCursorRow is relative | |
| 105 | - | // to content and is used both to retain the pane's own viewport and to return | |
| 106 | - | // an absolute terminal cursor row. Pass -1 when the foreground has no text | |
| 107 | - | // cursor. | |
| 108 | - | func (p *FloatingPane) Compose(theme Theme, id string, background, content []string, cols, rows, contentCursorRow, contentCursorCol int) (pane FloatingPaneFrame, cursorRow, cursorCol int) { | |
| 104 | + | // Compose returns a complete floating-pane frame. title is centered in the | |
| 105 | + | // pane border. contentCursorRow is relative to content and is used both to | |
| 106 | + | // retain the pane's own viewport and to return an absolute terminal cursor | |
| 107 | + | // row. Pass -1 when the foreground has no text cursor. | |
| 108 | + | func (p *FloatingPane) Compose(theme Theme, id, title string, background, content []string, cols, rows, contentCursorRow, contentCursorCol int) (pane FloatingPaneFrame, cursorRow, cursorCol int) { | |
| 109 | 109 | pane.Background = DimLines(fitFloatingBackground(background, cols, rows)) | |
| 110 | 110 | if cols < 1 || rows < 1 { | |
| 111 | 111 | return pane, -1, 0 | |
| @@ -141,7 +141,7 @@ func (p *FloatingPane) Compose(theme Theme, id string, background, content []str | |||
| 141 | 141 | viewportRows := pane.Rect.ContentHeight() | |
| 142 | 142 | p.follow(contentCursorRow, len(content), viewportRows) | |
| 143 | 143 | content = p.viewport(content, viewportRows) | |
| 144 | - | pane.Lines = floatingPaneLines(theme, pane.Rect, content) | |
| 144 | + | pane.Lines = floatingPaneLines(theme, pane.Rect, title, content) | |
| 145 | 145 | ||
| 146 | 146 | if contentCursorRow >= p.scroll && contentCursorRow < p.scroll+viewportRows { | |
| 147 | 147 | cursorRow = pane.Rect.contentY() + contentCursorRow - p.scroll | |
| @@ -200,7 +200,7 @@ func fitFloatingBackground(background []string, cols, rows int) []string { | |||
| 200 | 200 | return frame | |
| 201 | 201 | } | |
| 202 | 202 | ||
| 203 | - | func floatingPaneLines(theme Theme, rect FloatingPaneRect, content []string) []string { | |
| 203 | + | func floatingPaneLines(theme Theme, rect FloatingPaneRect, title string, content []string) []string { | |
| 204 | 204 | if rect.Width < 1 || rect.Height < 1 { | |
| 205 | 205 | return nil | |
| 206 | 206 | } | |
| @@ -212,7 +212,7 @@ func floatingPaneLines(theme Theme, rect FloatingPaneRect, content []string) []s | |||
| 212 | 212 | } | |
| 213 | 213 | lines := make([]string, rect.Height) | |
| 214 | 214 | if rect.Drawer { | |
| 215 | - | lines[0] = theme.FGColor(theme.Muted, strings.Repeat("─", rect.Width)) | |
| 215 | + | lines[0] = floatingPaneTitle(theme, title, rect.Width, false) | |
| 216 | 216 | for row := 0; row < rect.ContentHeight(); row++ { | |
| 217 | 217 | line := "" | |
| 218 | 218 | if row < len(content) { | |
| @@ -226,7 +226,7 @@ func floatingPaneLines(theme Theme, rect FloatingPaneRect, content []string) []s | |||
| 226 | 226 | return lines | |
| 227 | 227 | } | |
| 228 | 228 | border := theme.FGColor(theme.Muted, "│") | |
| 229 | - | lines[0] = theme.FGColor(theme.Muted, "╭"+strings.Repeat("─", rect.Width-2)+"╮") | |
| 229 | + | lines[0] = floatingPaneTitle(theme, title, rect.Width, true) | |
| 230 | 230 | lines[len(lines)-1] = theme.FGColor(theme.Muted, "╰"+strings.Repeat("─", rect.Width-2)+"╯") | |
| 231 | 231 | for row := 0; row < rect.ContentHeight(); row++ { | |
| 232 | 232 | line := "" | |
| @@ -240,3 +240,30 @@ func floatingPaneLines(theme Theme, rect FloatingPaneRect, content []string) []s | |||
| 240 | 240 | } | |
| 241 | 241 | return lines | |
| 242 | 242 | } | |
| 243 | + | ||
| 244 | + | func floatingPaneTitle(theme Theme, title string, width int, boxed bool) string { | |
| 245 | + | if width < 1 { | |
| 246 | + | return "" | |
| 247 | + | } | |
| 248 | + | interior := width | |
| 249 | + | left, right := "", "" | |
| 250 | + | if boxed { | |
| 251 | + | if width < 3 { | |
| 252 | + | return theme.FGColor(theme.Muted, strings.Repeat("─", width)) | |
| 253 | + | } | |
| 254 | + | interior -= 2 | |
| 255 | + | left, right = "╭", "╮" | |
| 256 | + | } | |
| 257 | + | label := "" | |
| 258 | + | if title != "" { | |
| 259 | + | label = " " + truncateToWidth(title, max(1, interior-2)) + " " | |
| 260 | + | } | |
| 261 | + | labelWidth := visibleWidth(label) | |
| 262 | + | if labelWidth > interior { | |
| 263 | + | label = truncateToWidth(label, interior) | |
| 264 | + | labelWidth = visibleWidth(label) | |
| 265 | + | } | |
| 266 | + | before := max(0, (interior-labelWidth)/2) | |
| 267 | + | after := max(0, interior-labelWidth-before) | |
| 268 | + | return theme.FGColor(theme.Muted, left+strings.Repeat("─", before)+label+strings.Repeat("─", after)+right) | |
| 269 | + | } | |
| @@ -101,11 +101,11 @@ type FloatingPaneFrame struct { | |||||
| 101 | Rect FloatingPaneRect | 101 | Rect FloatingPaneRect | ||
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | - | // Compose returns a complete floating-pane frame. contentCursorRow is relative | 104 | + | // Compose returns a complete floating-pane frame. title is centered in the |
| 105 | - | // to content and is used both to retain the pane's own viewport and to return | 105 | + | // pane border. contentCursorRow is relative to content and is used both to |
| 106 | - | // an absolute terminal cursor row. Pass -1 when the foreground has no text | 106 | + | // retain the pane's own viewport and to return an absolute terminal cursor |
| 107 | - | // cursor. | 107 | + | // row. Pass -1 when the foreground has no text cursor. |
| 108 | - | func (p *FloatingPane) Compose(theme Theme, id string, background, content []string, cols, rows, contentCursorRow, contentCursorCol int) (pane FloatingPaneFrame, cursorRow, cursorCol int) { | 108 | + | func (p *FloatingPane) Compose(theme Theme, id, title string, background, content []string, cols, rows, contentCursorRow, contentCursorCol int) (pane FloatingPaneFrame, cursorRow, cursorCol int) { |
| 109 | pane.Background = DimLines(fitFloatingBackground(background, cols, rows)) | 109 | pane.Background = DimLines(fitFloatingBackground(background, cols, rows)) | ||
| 110 | if cols < 1 || rows < 1 { | 110 | if cols < 1 || rows < 1 { | ||
| 111 | return pane, -1, 0 | 111 | return pane, -1, 0 | ||
| @@ -141,7 +141,7 @@ func (p *FloatingPane) Compose(theme Theme, id string, background, content []str | |||||
| 141 | viewportRows := pane.Rect.ContentHeight() | 141 | viewportRows := pane.Rect.ContentHeight() | ||
| 142 | p.follow(contentCursorRow, len(content), viewportRows) | 142 | p.follow(contentCursorRow, len(content), viewportRows) | ||
| 143 | content = p.viewport(content, viewportRows) | 143 | content = p.viewport(content, viewportRows) | ||
| 144 | - | pane.Lines = floatingPaneLines(theme, pane.Rect, content) | 144 | + | pane.Lines = floatingPaneLines(theme, pane.Rect, title, content) |
| 145 | 145 | ||||
| 146 | if contentCursorRow >= p.scroll && contentCursorRow < p.scroll+viewportRows { | 146 | if contentCursorRow >= p.scroll && contentCursorRow < p.scroll+viewportRows { | ||
| 147 | cursorRow = pane.Rect.contentY() + contentCursorRow - p.scroll | 147 | cursorRow = pane.Rect.contentY() + contentCursorRow - p.scroll | ||
| @@ -200,7 +200,7 @@ func fitFloatingBackground(background []string, cols, rows int) []string { | |||||
| 200 | return frame | 200 | return frame | ||
| 201 | } | 201 | } | ||
| 202 | 202 | ||||
| 203 | - | func floatingPaneLines(theme Theme, rect FloatingPaneRect, content []string) []string { | 203 | + | func floatingPaneLines(theme Theme, rect FloatingPaneRect, title string, content []string) []string { |
| 204 | if rect.Width < 1 || rect.Height < 1 { | 204 | if rect.Width < 1 || rect.Height < 1 { | ||
| 205 | return nil | 205 | return nil | ||
| 206 | } | 206 | } | ||
| @@ -212,7 +212,7 @@ func floatingPaneLines(theme Theme, rect FloatingPaneRect, content []string) []s | |||||
| 212 | } | 212 | } | ||
| 213 | lines := make([]string, rect.Height) | 213 | lines := make([]string, rect.Height) | ||
| 214 | if rect.Drawer { | 214 | if rect.Drawer { | ||
| 215 | - | lines[0] = theme.FGColor(theme.Muted, strings.Repeat("─", rect.Width)) | 215 | + | lines[0] = floatingPaneTitle(theme, title, rect.Width, false) |
| 216 | for row := 0; row < rect.ContentHeight(); row++ { | 216 | for row := 0; row < rect.ContentHeight(); row++ { | ||
| 217 | line := "" | 217 | line := "" | ||
| 218 | if row < len(content) { | 218 | if row < len(content) { | ||
| @@ -226,7 +226,7 @@ func floatingPaneLines(theme Theme, rect FloatingPaneRect, content []string) []s | |||||
| 226 | return lines | 226 | return lines | ||
| 227 | } | 227 | } | ||
| 228 | border := theme.FGColor(theme.Muted, "│") | 228 | border := theme.FGColor(theme.Muted, "│") | ||
| 229 | - | lines[0] = theme.FGColor(theme.Muted, "╭"+strings.Repeat("─", rect.Width-2)+"╮") | 229 | + | lines[0] = floatingPaneTitle(theme, title, rect.Width, true) |
| 230 | lines[len(lines)-1] = theme.FGColor(theme.Muted, "╰"+strings.Repeat("─", rect.Width-2)+"╯") | 230 | lines[len(lines)-1] = theme.FGColor(theme.Muted, "╰"+strings.Repeat("─", rect.Width-2)+"╯") | ||
| 231 | for row := 0; row < rect.ContentHeight(); row++ { | 231 | for row := 0; row < rect.ContentHeight(); row++ { | ||
| 232 | line := "" | 232 | line := "" | ||
| @@ -240,3 +240,30 @@ func floatingPaneLines(theme Theme, rect FloatingPaneRect, content []string) []s | |||||
| 240 | } | 240 | } | ||
| 241 | return lines | 241 | return lines | ||
| 242 | } | 242 | } | ||
| 243 | + | ||||
| 244 | + | func floatingPaneTitle(theme Theme, title string, width int, boxed bool) string { | |||
| 245 | + | if width < 1 { | |||
| 246 | + | return "" | |||
| 247 | + | } | |||
| 248 | + | interior := width | |||
| 249 | + | left, right := "", "" | |||
| 250 | + | if boxed { | |||
| 251 | + | if width < 3 { | |||
| 252 | + | return theme.FGColor(theme.Muted, strings.Repeat("─", width)) | |||
| 253 | + | } | |||
| 254 | + | interior -= 2 | |||
| 255 | + | left, right = "╭", "╮" | |||
| 256 | + | } | |||
| 257 | + | label := "" | |||
| 258 | + | if title != "" { | |||
| 259 | + | label = " " + truncateToWidth(title, max(1, interior-2)) + " " | |||
| 260 | + | } | |||
| 261 | + | labelWidth := visibleWidth(label) | |||
| 262 | + | if labelWidth > interior { | |||
| 263 | + | label = truncateToWidth(label, interior) | |||
| 264 | + | labelWidth = visibleWidth(label) | |||
| 265 | + | } | |||
| 266 | + | before := max(0, (interior-labelWidth)/2) | |||
| 267 | + | after := max(0, interior-labelWidth-before) | |||
| 268 | + | return theme.FGColor(theme.Muted, left+strings.Repeat("─", before)+label+strings.Repeat("─", after)+right) | |||
| 269 | + | } | |||
M packages/tui/floating_pane_test.go +19 -11
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | func TestFloatingPaneComposeCentersAndDimsLiveBackground(t *testing.T) { | |
| 9 | 9 | var pane FloatingPane | |
| 10 | - | frame, cursorRow, cursorCol := pane.Compose(Dark, "settings", []string{"live transcript", "draft input"}, []string{"settings body"}, 100, 30, -1, 0) | |
| 10 | + | frame, cursorRow, cursorCol := pane.Compose(Dark, "settings", "settings", []string{"live transcript", "draft input"}, []string{"settings body"}, 100, 30, -1, 0) | |
| 11 | 11 | ||
| 12 | 12 | if frame.Rect.Drawer { | |
| 13 | 13 | t.Fatal("100-column terminal unexpectedly used drawer layout") | |
| @@ -20,6 +20,14 @@ func TestFloatingPaneComposeCentersAndDimsLiveBackground(t *testing.T) { | |||
| 20 | 20 | } | |
| 21 | 21 | if got := stripANSI(frame.Lines[0]); !strings.HasPrefix(got, "╭") { | |
| 22 | 22 | t.Fatalf("pane is missing top border: %q", got) | |
| 23 | + | } else if title := strings.Index(got, "settings"); title < 0 { | |
| 24 | + | t.Fatalf("pane title is missing from its border: %q", got) | |
| 25 | + | } else { | |
| 26 | + | left := strings.Count(got[:title], "─") | |
| 27 | + | right := strings.Count(got[title+len("settings"):], "─") | |
| 28 | + | if left-right > 1 || right-left > 1 { | |
| 29 | + | t.Fatalf("pane title is not centered in its border: %q", got) | |
| 30 | + | } | |
| 23 | 31 | } | |
| 24 | 32 | if cursorRow != -1 || cursorCol != 0 { | |
| 25 | 33 | t.Fatalf("cursor = (%d, %d), want hidden", cursorRow, cursorCol) | |
| @@ -28,7 +36,7 @@ func TestFloatingPaneComposeCentersAndDimsLiveBackground(t *testing.T) { | |||
| 28 | 36 | ||
| 29 | 37 | func TestFloatingPaneUsesBottomDrawerBelowEightyColumns(t *testing.T) { | |
| 30 | 38 | var pane FloatingPane | |
| 31 | - | frame, _, _ := pane.Compose(Dark, "settings", nil, []string{"settings body"}, 79, 24, -1, 0) | |
| 39 | + | frame, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"settings body"}, 79, 24, -1, 0) | |
| 32 | 40 | ||
| 33 | 41 | if !frame.Rect.Drawer { | |
| 34 | 42 | t.Fatal("79-column terminal did not use drawer layout") | |
| @@ -36,15 +44,15 @@ func TestFloatingPaneUsesBottomDrawerBelowEightyColumns(t *testing.T) { | |||
| 36 | 44 | if frame.Rect.X != 0 || frame.Rect.Width != 79 || frame.Rect.Y+frame.Rect.Height != 24 { | |
| 37 | 45 | t.Fatalf("drawer geometry = %+v, want full-width bottom drawer", frame.Rect) | |
| 38 | 46 | } | |
| 39 | - | if got := stripANSI(frame.Lines[0]); strings.Trim(got, "─") != "" { | |
| 40 | - | t.Fatalf("drawer top border = %q, want rule only", got) | |
| 47 | + | if got := stripANSI(frame.Lines[0]); !strings.Contains(got, "settings") { | |
| 48 | + | t.Fatalf("drawer title = %q, want centered settings title", got) | |
| 41 | 49 | } | |
| 42 | 50 | } | |
| 43 | 51 | ||
| 44 | 52 | func TestFloatingPaneRecomputesGeometryOnResize(t *testing.T) { | |
| 45 | 53 | var pane FloatingPane | |
| 46 | - | wide, _, _ := pane.Compose(Dark, "settings", nil, []string{"setting"}, 100, 30, -1, 0) | |
| 47 | - | narrow, _, _ := pane.Compose(Dark, "settings", nil, []string{"setting"}, 79, 24, -1, 0) | |
| 54 | + | wide, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"setting"}, 100, 30, -1, 0) | |
| 55 | + | narrow, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"setting"}, 79, 24, -1, 0) | |
| 48 | 56 | ||
| 49 | 57 | if wide.Rect.Drawer { | |
| 50 | 58 | t.Fatal("wide terminal unexpectedly used a drawer") | |
| @@ -56,12 +64,12 @@ func TestFloatingPaneRecomputesGeometryOnResize(t *testing.T) { | |||
| 56 | 64 | ||
| 57 | 65 | func TestFloatingPaneClampsCursorToContentAndHandlesTinyTerminals(t *testing.T) { | |
| 58 | 66 | var pane FloatingPane | |
| 59 | - | frame, _, col := pane.Compose(Dark, "search", nil, []string{"query"}, 100, 20, 0, 1000) | |
| 67 | + | frame, _, col := pane.Compose(Dark, "search", "search", nil, []string{"query"}, 100, 20, 0, 1000) | |
| 60 | 68 | if want := frame.Rect.contentX() + frame.Rect.ContentWidth() - 1; col != want { | |
| 61 | 69 | t.Fatalf("cursor col = %d, want content edge %d", col, want) | |
| 62 | 70 | } | |
| 63 | 71 | ||
| 64 | - | tiny, row, tinyCol := pane.Compose(Dark, "search", nil, []string{"x"}, 2, 2, 0, 10) | |
| 72 | + | tiny, row, tinyCol := pane.Compose(Dark, "search", "search", nil, []string{"x"}, 2, 2, 0, 10) | |
| 65 | 73 | if !tiny.Rect.Borderless { | |
| 66 | 74 | t.Fatalf("tiny terminal rect = %+v, want borderless", tiny.Rect) | |
| 67 | 75 | } | |
| @@ -76,7 +84,7 @@ func TestFloatingPaneKeepsFocusedContentVisibleAndResetsForOtherView(t *testing. | |||
| 76 | 84 | content[row] = "row " + itoa(row) | |
| 77 | 85 | } | |
| 78 | 86 | var pane FloatingPane | |
| 79 | - | frame, cursorRow, _ := pane.Compose(Dark, "settings", nil, content, 100, 12, 25, 0) | |
| 87 | + | frame, cursorRow, _ := pane.Compose(Dark, "settings", "settings", nil, content, 100, 12, 25, 0) | |
| 80 | 88 | if cursorRow < frame.Rect.Y+1 || cursorRow >= frame.Rect.Y+frame.Rect.Height-1 { | |
| 81 | 89 | t.Fatalf("focused cursor row %d is outside pane %+v", cursorRow, frame.Rect) | |
| 82 | 90 | } | |
| @@ -84,7 +92,7 @@ func TestFloatingPaneKeepsFocusedContentVisibleAndResetsForOtherView(t *testing. | |||
| 84 | 92 | t.Fatal("focused row was not retained in the pane viewport") | |
| 85 | 93 | } | |
| 86 | 94 | ||
| 87 | - | other, _, _ := pane.Compose(Dark, "subagents", nil, content, 100, 12, -1, 0) | |
| 95 | + | other, _, _ := pane.Compose(Dark, "subagents", "subagents", nil, content, 100, 12, -1, 0) | |
| 88 | 96 | if strings.Contains(stripANSI(strings.Join(other.Lines, "\n")), "row 25") { | |
| 89 | 97 | t.Fatal("new view inherited the previous view's scroll position") | |
| 90 | 98 | } | |
| @@ -95,7 +103,7 @@ func TestRendererDrawFloatingPaintsBackgroundThenPaneAtAbsolutePosition(t *testi | |||
| 95 | 103 | r := NewRenderer(&out) | |
| 96 | 104 | r.Resize(100, 20) | |
| 97 | 105 | var pane FloatingPane | |
| 98 | - | frame, _, _ := pane.Compose(Dark, "settings", []string{"background"}, []string{"foreground"}, 100, 20, -1, 0) | |
| 106 | + | frame, _, _ := pane.Compose(Dark, "settings", "settings", []string{"background"}, []string{"foreground"}, 100, 20, -1, 0) | |
| 99 | 107 | r.DrawFloating(frame, -1, 0) | |
| 100 | 108 | got := out.String() | |
| 101 | 109 | if !strings.Contains(got, Dim("background")) || !strings.Contains(got, "foreground") { | |
| @@ -7,7 +7,7 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | func TestFloatingPaneComposeCentersAndDimsLiveBackground(t *testing.T) { | 8 | func TestFloatingPaneComposeCentersAndDimsLiveBackground(t *testing.T) { | ||
| 9 | var pane FloatingPane | 9 | var pane FloatingPane | ||
| 10 | - | frame, cursorRow, cursorCol := pane.Compose(Dark, "settings", []string{"live transcript", "draft input"}, []string{"settings body"}, 100, 30, -1, 0) | 10 | + | frame, cursorRow, cursorCol := pane.Compose(Dark, "settings", "settings", []string{"live transcript", "draft input"}, []string{"settings body"}, 100, 30, -1, 0) |
| 11 | 11 | ||||
| 12 | if frame.Rect.Drawer { | 12 | if frame.Rect.Drawer { | ||
| 13 | t.Fatal("100-column terminal unexpectedly used drawer layout") | 13 | t.Fatal("100-column terminal unexpectedly used drawer layout") | ||
| @@ -20,6 +20,14 @@ func TestFloatingPaneComposeCentersAndDimsLiveBackground(t *testing.T) { | |||||
| 20 | } | 20 | } | ||
| 21 | if got := stripANSI(frame.Lines[0]); !strings.HasPrefix(got, "╭") { | 21 | if got := stripANSI(frame.Lines[0]); !strings.HasPrefix(got, "╭") { | ||
| 22 | t.Fatalf("pane is missing top border: %q", got) | 22 | t.Fatalf("pane is missing top border: %q", got) | ||
| 23 | + | } else if title := strings.Index(got, "settings"); title < 0 { | |||
| 24 | + | t.Fatalf("pane title is missing from its border: %q", got) | |||
| 25 | + | } else { | |||
| 26 | + | left := strings.Count(got[:title], "─") | |||
| 27 | + | right := strings.Count(got[title+len("settings"):], "─") | |||
| 28 | + | if left-right > 1 || right-left > 1 { | |||
| 29 | + | t.Fatalf("pane title is not centered in its border: %q", got) | |||
| 30 | + | } | |||
| 23 | } | 31 | } | ||
| 24 | if cursorRow != -1 || cursorCol != 0 { | 32 | if cursorRow != -1 || cursorCol != 0 { | ||
| 25 | t.Fatalf("cursor = (%d, %d), want hidden", cursorRow, cursorCol) | 33 | t.Fatalf("cursor = (%d, %d), want hidden", cursorRow, cursorCol) | ||
| @@ -28,7 +36,7 @@ func TestFloatingPaneComposeCentersAndDimsLiveBackground(t *testing.T) { | |||||
| 28 | 36 | ||||
| 29 | func TestFloatingPaneUsesBottomDrawerBelowEightyColumns(t *testing.T) { | 37 | func TestFloatingPaneUsesBottomDrawerBelowEightyColumns(t *testing.T) { | ||
| 30 | var pane FloatingPane | 38 | var pane FloatingPane | ||
| 31 | - | frame, _, _ := pane.Compose(Dark, "settings", nil, []string{"settings body"}, 79, 24, -1, 0) | 39 | + | frame, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"settings body"}, 79, 24, -1, 0) |
| 32 | 40 | ||||
| 33 | if !frame.Rect.Drawer { | 41 | if !frame.Rect.Drawer { | ||
| 34 | t.Fatal("79-column terminal did not use drawer layout") | 42 | t.Fatal("79-column terminal did not use drawer layout") | ||
| @@ -36,15 +44,15 @@ func TestFloatingPaneUsesBottomDrawerBelowEightyColumns(t *testing.T) { | |||||
| 36 | if frame.Rect.X != 0 || frame.Rect.Width != 79 || frame.Rect.Y+frame.Rect.Height != 24 { | 44 | if frame.Rect.X != 0 || frame.Rect.Width != 79 || frame.Rect.Y+frame.Rect.Height != 24 { | ||
| 37 | t.Fatalf("drawer geometry = %+v, want full-width bottom drawer", frame.Rect) | 45 | t.Fatalf("drawer geometry = %+v, want full-width bottom drawer", frame.Rect) | ||
| 38 | } | 46 | } | ||
| 39 | - | if got := stripANSI(frame.Lines[0]); strings.Trim(got, "─") != "" { | 47 | + | if got := stripANSI(frame.Lines[0]); !strings.Contains(got, "settings") { |
| 40 | - | t.Fatalf("drawer top border = %q, want rule only", got) | 48 | + | t.Fatalf("drawer title = %q, want centered settings title", got) |
| 41 | } | 49 | } | ||
| 42 | } | 50 | } | ||
| 43 | 51 | ||||
| 44 | func TestFloatingPaneRecomputesGeometryOnResize(t *testing.T) { | 52 | func TestFloatingPaneRecomputesGeometryOnResize(t *testing.T) { | ||
| 45 | var pane FloatingPane | 53 | var pane FloatingPane | ||
| 46 | - | wide, _, _ := pane.Compose(Dark, "settings", nil, []string{"setting"}, 100, 30, -1, 0) | 54 | + | wide, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"setting"}, 100, 30, -1, 0) |
| 47 | - | narrow, _, _ := pane.Compose(Dark, "settings", nil, []string{"setting"}, 79, 24, -1, 0) | 55 | + | narrow, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"setting"}, 79, 24, -1, 0) |
| 48 | 56 | ||||
| 49 | if wide.Rect.Drawer { | 57 | if wide.Rect.Drawer { | ||
| 50 | t.Fatal("wide terminal unexpectedly used a drawer") | 58 | t.Fatal("wide terminal unexpectedly used a drawer") | ||
| @@ -56,12 +64,12 @@ func TestFloatingPaneRecomputesGeometryOnResize(t *testing.T) { | |||||
| 56 | 64 | ||||
| 57 | func TestFloatingPaneClampsCursorToContentAndHandlesTinyTerminals(t *testing.T) { | 65 | func TestFloatingPaneClampsCursorToContentAndHandlesTinyTerminals(t *testing.T) { | ||
| 58 | var pane FloatingPane | 66 | var pane FloatingPane | ||
| 59 | - | frame, _, col := pane.Compose(Dark, "search", nil, []string{"query"}, 100, 20, 0, 1000) | 67 | + | frame, _, col := pane.Compose(Dark, "search", "search", nil, []string{"query"}, 100, 20, 0, 1000) |
| 60 | if want := frame.Rect.contentX() + frame.Rect.ContentWidth() - 1; col != want { | 68 | if want := frame.Rect.contentX() + frame.Rect.ContentWidth() - 1; col != want { | ||
| 61 | t.Fatalf("cursor col = %d, want content edge %d", col, want) | 69 | t.Fatalf("cursor col = %d, want content edge %d", col, want) | ||
| 62 | } | 70 | } | ||
| 63 | 71 | ||||
| 64 | - | tiny, row, tinyCol := pane.Compose(Dark, "search", nil, []string{"x"}, 2, 2, 0, 10) | 72 | + | tiny, row, tinyCol := pane.Compose(Dark, "search", "search", nil, []string{"x"}, 2, 2, 0, 10) |
| 65 | if !tiny.Rect.Borderless { | 73 | if !tiny.Rect.Borderless { | ||
| 66 | t.Fatalf("tiny terminal rect = %+v, want borderless", tiny.Rect) | 74 | t.Fatalf("tiny terminal rect = %+v, want borderless", tiny.Rect) | ||
| 67 | } | 75 | } | ||
| @@ -76,7 +84,7 @@ func TestFloatingPaneKeepsFocusedContentVisibleAndResetsForOtherView(t *testing. | |||||
| 76 | content[row] = "row " + itoa(row) | 84 | content[row] = "row " + itoa(row) | ||
| 77 | } | 85 | } | ||
| 78 | var pane FloatingPane | 86 | var pane FloatingPane | ||
| 79 | - | frame, cursorRow, _ := pane.Compose(Dark, "settings", nil, content, 100, 12, 25, 0) | 87 | + | frame, cursorRow, _ := pane.Compose(Dark, "settings", "settings", nil, content, 100, 12, 25, 0) |
| 80 | if cursorRow < frame.Rect.Y+1 || cursorRow >= frame.Rect.Y+frame.Rect.Height-1 { | 88 | if cursorRow < frame.Rect.Y+1 || cursorRow >= frame.Rect.Y+frame.Rect.Height-1 { | ||
| 81 | t.Fatalf("focused cursor row %d is outside pane %+v", cursorRow, frame.Rect) | 89 | t.Fatalf("focused cursor row %d is outside pane %+v", cursorRow, frame.Rect) | ||
| 82 | } | 90 | } | ||
| @@ -84,7 +92,7 @@ func TestFloatingPaneKeepsFocusedContentVisibleAndResetsForOtherView(t *testing. | |||||
| 84 | t.Fatal("focused row was not retained in the pane viewport") | 92 | t.Fatal("focused row was not retained in the pane viewport") | ||
| 85 | } | 93 | } | ||
| 86 | 94 | ||||
| 87 | - | other, _, _ := pane.Compose(Dark, "subagents", nil, content, 100, 12, -1, 0) | 95 | + | other, _, _ := pane.Compose(Dark, "subagents", "subagents", nil, content, 100, 12, -1, 0) |
| 88 | if strings.Contains(stripANSI(strings.Join(other.Lines, "\n")), "row 25") { | 96 | if strings.Contains(stripANSI(strings.Join(other.Lines, "\n")), "row 25") { | ||
| 89 | t.Fatal("new view inherited the previous view's scroll position") | 97 | t.Fatal("new view inherited the previous view's scroll position") | ||
| 90 | } | 98 | } | ||
| @@ -95,7 +103,7 @@ func TestRendererDrawFloatingPaintsBackgroundThenPaneAtAbsolutePosition(t *testi | |||||
| 95 | r := NewRenderer(&out) | 103 | r := NewRenderer(&out) | ||
| 96 | r.Resize(100, 20) | 104 | r.Resize(100, 20) | ||
| 97 | var pane FloatingPane | 105 | var pane FloatingPane | ||
| 98 | - | frame, _, _ := pane.Compose(Dark, "settings", []string{"background"}, []string{"foreground"}, 100, 20, -1, 0) | 106 | + | frame, _, _ := pane.Compose(Dark, "settings", "settings", []string{"background"}, []string{"foreground"}, 100, 20, -1, 0) |
| 99 | r.DrawFloating(frame, -1, 0) | 107 | r.DrawFloating(frame, -1, 0) | ||
| 100 | got := out.String() | 108 | got := out.String() | ||
| 101 | if !strings.Contains(got, Dim("background")) || !strings.Contains(got, "foreground") { | 109 | if !strings.Contains(got, Dim("background")) || !strings.Contains(got, "foreground") { | ||