Merge pull request #38 from bnema/fix/right-bar-right-padding
fix(tui): pad right bar content
2 files · +43 -7
M packages/tui/rightbar.go +16 -5
| @@ -16,6 +16,10 @@ const ( | |||
| 16 | 16 | RightBarMaxWidth = 36 | |
| 17 | 17 | // RightBarMinMainWidth preserves enough room for the editor and chat. | |
| 18 | 18 | RightBarMinMainWidth = 48 | |
| 19 | + | ||
| 20 | + | // rightBarRightPadding leaves a small gutter between widget text and the | |
| 21 | + | // terminal's right edge. | |
| 22 | + | rightBarRightPadding = 1 | |
| 19 | 23 | ) | |
| 20 | 24 | ||
| 21 | 25 | // RightBarWidget is declarative content for one persistent extension widget. | |
| @@ -60,13 +64,14 @@ func RightBarColumns(cols int) (mainWidth, rightBarWidth int, ok bool) { | |||
| 60 | 64 | // RenderRightBar renders a bounded, full-height side rail without adding a | |
| 61 | 65 | // second frame around the host-owned separator. Widgets are sorted by | |
| 62 | 66 | // extension name and ID so output stays deterministic even when extension | |
| 63 | - | // frames arrive concurrently. Long lines are clipped to the rail width with | |
| 64 | - | // a three-dot ellipsis. | |
| 67 | + | // frames arrive concurrently. Long lines are clipped with a three-dot ellipsis | |
| 68 | + | // while reserving one cell of right padding. | |
| 65 | 69 | func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string { | |
| 66 | 70 | if width <= 0 || height <= 0 { | |
| 67 | 71 | return nil | |
| 68 | 72 | } | |
| 69 | 73 | ||
| 74 | + | contentWidth := width - rightBarRightPadding | |
| 70 | 75 | ordered := append([]RightBarWidget(nil), widgets...) | |
| 71 | 76 | sort.SliceStable(ordered, func(a, b int) bool { | |
| 72 | 77 | if ordered[a].Extension != ordered[b].Extension { | |
| @@ -78,10 +83,13 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||
| 78 | 83 | content := make([]string, 0, height) | |
| 79 | 84 | truncated := false | |
| 80 | 85 | padLine := func(text string, phase bool) string { | |
| 86 | + | if contentWidth <= 0 { | |
| 87 | + | return strings.Repeat(" ", width) | |
| 88 | + | } | |
| 81 | 89 | if phase { | |
| 82 | - | text = truncateRightBarPhaseLine(text, width) | |
| 90 | + | text = truncateRightBarPhaseLine(text, contentWidth) | |
| 83 | 91 | } else { | |
| 84 | - | text = truncateRightBarLine(text, width) | |
| 92 | + | text = truncateRightBarLine(text, contentWidth) | |
| 85 | 93 | } | |
| 86 | 94 | if visible := visibleWidth(text); visible < width { | |
| 87 | 95 | text += strings.Repeat(" ", width-visible) | |
| @@ -216,7 +224,10 @@ func truncateRightBarPhaseLine(text string, width int) string { | |||
| 216 | 224 | prefix := text[:nameStart] | |
| 217 | 225 | suffix := text[nameEnd:] | |
| 218 | 226 | nameWidth := width - visibleWidth(prefix) - visibleWidth(suffix) | |
| 219 | - | if nameWidth <= 0 { | |
| 227 | + | if nameWidth == 0 { | |
| 228 | + | return prefix + suffix | |
| 229 | + | } | |
| 230 | + | if nameWidth < 0 { | |
| 220 | 231 | return truncateRightBarLine(text, width) | |
| 221 | 232 | } | |
| 222 | 233 | return prefix + truncateRightBarLine(text[nameStart:nameEnd], nameWidth) + suffix | |
| @@ -16,6 +16,10 @@ const ( | |||||
| 16 | RightBarMaxWidth = 36 | 16 | RightBarMaxWidth = 36 | ||
| 17 | // RightBarMinMainWidth preserves enough room for the editor and chat. | 17 | // RightBarMinMainWidth preserves enough room for the editor and chat. | ||
| 18 | RightBarMinMainWidth = 48 | 18 | RightBarMinMainWidth = 48 | ||
| 19 | + | ||||
| 20 | + | // rightBarRightPadding leaves a small gutter between widget text and the | |||
| 21 | + | // terminal's right edge. | |||
| 22 | + | rightBarRightPadding = 1 | |||
| 19 | ) | 23 | ) | ||
| 20 | 24 | ||||
| 21 | // RightBarWidget is declarative content for one persistent extension widget. | 25 | // RightBarWidget is declarative content for one persistent extension widget. | ||
| @@ -60,13 +64,14 @@ func RightBarColumns(cols int) (mainWidth, rightBarWidth int, ok bool) { | |||||
| 60 | // RenderRightBar renders a bounded, full-height side rail without adding a | 64 | // RenderRightBar renders a bounded, full-height side rail without adding a | ||
| 61 | // second frame around the host-owned separator. Widgets are sorted by | 65 | // second frame around the host-owned separator. Widgets are sorted by | ||
| 62 | // extension name and ID so output stays deterministic even when extension | 66 | // extension name and ID so output stays deterministic even when extension | ||
| 63 | - | // frames arrive concurrently. Long lines are clipped to the rail width with | 67 | + | // frames arrive concurrently. Long lines are clipped with a three-dot ellipsis |
| 64 | - | // a three-dot ellipsis. | 68 | + | // while reserving one cell of right padding. |
| 65 | func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string { | 69 | func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string { | ||
| 66 | if width <= 0 || height <= 0 { | 70 | if width <= 0 || height <= 0 { | ||
| 67 | return nil | 71 | return nil | ||
| 68 | } | 72 | } | ||
| 69 | 73 | ||||
| 74 | + | contentWidth := width - rightBarRightPadding | |||
| 70 | ordered := append([]RightBarWidget(nil), widgets...) | 75 | ordered := append([]RightBarWidget(nil), widgets...) | ||
| 71 | sort.SliceStable(ordered, func(a, b int) bool { | 76 | sort.SliceStable(ordered, func(a, b int) bool { | ||
| 72 | if ordered[a].Extension != ordered[b].Extension { | 77 | if ordered[a].Extension != ordered[b].Extension { | ||
| @@ -78,10 +83,13 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||||
| 78 | content := make([]string, 0, height) | 83 | content := make([]string, 0, height) | ||
| 79 | truncated := false | 84 | truncated := false | ||
| 80 | padLine := func(text string, phase bool) string { | 85 | padLine := func(text string, phase bool) string { | ||
| 86 | + | if contentWidth <= 0 { | |||
| 87 | + | return strings.Repeat(" ", width) | |||
| 88 | + | } | |||
| 81 | if phase { | 89 | if phase { | ||
| 82 | - | text = truncateRightBarPhaseLine(text, width) | 90 | + | text = truncateRightBarPhaseLine(text, contentWidth) |
| 83 | } else { | 91 | } else { | ||
| 84 | - | text = truncateRightBarLine(text, width) | 92 | + | text = truncateRightBarLine(text, contentWidth) |
| 85 | } | 93 | } | ||
| 86 | if visible := visibleWidth(text); visible < width { | 94 | if visible := visibleWidth(text); visible < width { | ||
| 87 | text += strings.Repeat(" ", width-visible) | 95 | text += strings.Repeat(" ", width-visible) | ||
| @@ -216,7 +224,10 @@ func truncateRightBarPhaseLine(text string, width int) string { | |||||
| 216 | prefix := text[:nameStart] | 224 | prefix := text[:nameStart] | ||
| 217 | suffix := text[nameEnd:] | 225 | suffix := text[nameEnd:] | ||
| 218 | nameWidth := width - visibleWidth(prefix) - visibleWidth(suffix) | 226 | nameWidth := width - visibleWidth(prefix) - visibleWidth(suffix) | ||
| 219 | - | if nameWidth <= 0 { | 227 | + | if nameWidth == 0 { |
| 228 | + | return prefix + suffix | |||
| 229 | + | } | |||
| 230 | + | if nameWidth < 0 { | |||
| 220 | return truncateRightBarLine(text, width) | 231 | return truncateRightBarLine(text, width) | ||
| 221 | } | 232 | } | ||
| 222 | return prefix + truncateRightBarLine(text[nameStart:nameEnd], nameWidth) + suffix | 233 | return prefix + truncateRightBarLine(text[nameStart:nameEnd], nameWidth) + suffix | ||
M packages/tui/rightbar_test.go +27 -2
| @@ -61,7 +61,7 @@ func TestRenderRightBarClipsLongWidgetLinesWithEllipsis(t *testing.T) { | |||
| 61 | 61 | t.Fatalf("right bar rows = %d, want 8", len(lines)) | |
| 62 | 62 | } | |
| 63 | 63 | plain := stripANSI(strings.Join(lines, "\n")) | |
| 64 | - | if !strings.Contains(plain, " one two thre...") || strings.Contains(plain, "four five six") { | |
| 64 | + | if !strings.Contains(plain, " one two thr...") || strings.Contains(plain, "four five six") { | |
| 65 | 65 | t.Fatalf("long widget line was not clipped with an ellipsis: %q", plain) | |
| 66 | 66 | } | |
| 67 | 67 | for idx, line := range lines { | |
| @@ -71,6 +71,24 @@ func TestRenderRightBarClipsLongWidgetLinesWithEllipsis(t *testing.T) { | |||
| 71 | 71 | } | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | + | func TestRenderRightBarLeavesOneCellOfRightPadding(t *testing.T) { | |
| 75 | + | lines := RenderRightBar(Dark, []RightBarWidget{{ | |
| 76 | + | Extension: "tasked-phases", | |
| 77 | + | Title: "p 0/1 | t 0/1", | |
| 78 | + | Lines: []string{"[>] A phase title that is much too long to fit 0/1"}, | |
| 79 | + | }}, 24, 4) | |
| 80 | + | ||
| 81 | + | for idx, line := range lines { | |
| 82 | + | plain := stripANSI(line) | |
| 83 | + | if !strings.HasSuffix(plain, " ") { | |
| 84 | + | t.Fatalf("right bar line %d lacks right padding: %q", idx, plain) | |
| 85 | + | } | |
| 86 | + | if visibleWidth(strings.TrimRight(plain, " ")) > 23 { | |
| 87 | + | t.Fatalf("right bar line %d content reaches the right edge: %q", idx, plain) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 74 | 92 | func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) { | |
| 75 | 93 | lines := RenderRightBar(Dark, []RightBarWidget{ | |
| 76 | 94 | { | |
| @@ -88,6 +106,13 @@ func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) { | |||
| 88 | 106 | } | |
| 89 | 107 | } | |
| 90 | 108 | ||
| 109 | + | func TestTruncateRightBarPhaseLinePreservesProgressWithoutRoomForName(t *testing.T) { | |
| 110 | + | got := truncateRightBarPhaseLine("[>] Current phase 0/1", 9) | |
| 111 | + | if want := "[>] 0/1"; got != want { | |
| 112 | + | t.Fatalf("truncated phase = %q, want %q", got, want) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 91 | 116 | func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) { | |
| 92 | 117 | lines := RenderRightBar(Dark, []RightBarWidget{{ | |
| 93 | 118 | Extension: "tasked-phases", | |
| @@ -105,7 +130,7 @@ func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) { | |||
| 105 | 130 | if len(plainLines) < 4 { | |
| 106 | 131 | t.Fatalf("checklist rows are missing: %q", plain) | |
| 107 | 132 | } | |
| 108 | - | expectedTask := truncateRightBarLine(" [ ] Sort the leftover star stickers", 36) | |
| 133 | + | const expectedTask = " [ ] Sort the leftover star sti..." | |
| 109 | 134 | if got := strings.TrimRight(plainLines[3], " "); got != expectedTask { | |
| 110 | 135 | t.Fatalf("task row = %q, want clipped row %q", got, expectedTask) | |
| 111 | 136 | } | |
| @@ -61,7 +61,7 @@ func TestRenderRightBarClipsLongWidgetLinesWithEllipsis(t *testing.T) { | |||||
| 61 | t.Fatalf("right bar rows = %d, want 8", len(lines)) | 61 | t.Fatalf("right bar rows = %d, want 8", len(lines)) | ||
| 62 | } | 62 | } | ||
| 63 | plain := stripANSI(strings.Join(lines, "\n")) | 63 | plain := stripANSI(strings.Join(lines, "\n")) | ||
| 64 | - | if !strings.Contains(plain, " one two thre...") || strings.Contains(plain, "four five six") { | 64 | + | if !strings.Contains(plain, " one two thr...") || strings.Contains(plain, "four five six") { |
| 65 | t.Fatalf("long widget line was not clipped with an ellipsis: %q", plain) | 65 | t.Fatalf("long widget line was not clipped with an ellipsis: %q", plain) | ||
| 66 | } | 66 | } | ||
| 67 | for idx, line := range lines { | 67 | for idx, line := range lines { | ||
| @@ -71,6 +71,24 @@ func TestRenderRightBarClipsLongWidgetLinesWithEllipsis(t *testing.T) { | |||||
| 71 | } | 71 | } | ||
| 72 | } | 72 | } | ||
| 73 | 73 | ||||
| 74 | + | func TestRenderRightBarLeavesOneCellOfRightPadding(t *testing.T) { | |||
| 75 | + | lines := RenderRightBar(Dark, []RightBarWidget{{ | |||
| 76 | + | Extension: "tasked-phases", | |||
| 77 | + | Title: "p 0/1 | t 0/1", | |||
| 78 | + | Lines: []string{"[>] A phase title that is much too long to fit 0/1"}, | |||
| 79 | + | }}, 24, 4) | |||
| 80 | + | ||||
| 81 | + | for idx, line := range lines { | |||
| 82 | + | plain := stripANSI(line) | |||
| 83 | + | if !strings.HasSuffix(plain, " ") { | |||
| 84 | + | t.Fatalf("right bar line %d lacks right padding: %q", idx, plain) | |||
| 85 | + | } | |||
| 86 | + | if visibleWidth(strings.TrimRight(plain, " ")) > 23 { | |||
| 87 | + | t.Fatalf("right bar line %d content reaches the right edge: %q", idx, plain) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 74 | func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) { | 92 | func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) { | ||
| 75 | lines := RenderRightBar(Dark, []RightBarWidget{ | 93 | lines := RenderRightBar(Dark, []RightBarWidget{ | ||
| 76 | { | 94 | { | ||
| @@ -88,6 +106,13 @@ func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) { | |||||
| 88 | } | 106 | } | ||
| 89 | } | 107 | } | ||
| 90 | 108 | ||||
| 109 | + | func TestTruncateRightBarPhaseLinePreservesProgressWithoutRoomForName(t *testing.T) { | |||
| 110 | + | got := truncateRightBarPhaseLine("[>] Current phase 0/1", 9) | |||
| 111 | + | if want := "[>] 0/1"; got != want { | |||
| 112 | + | t.Fatalf("truncated phase = %q, want %q", got, want) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 91 | func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) { | 116 | func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) { | ||
| 92 | lines := RenderRightBar(Dark, []RightBarWidget{{ | 117 | lines := RenderRightBar(Dark, []RightBarWidget{{ | ||
| 93 | Extension: "tasked-phases", | 118 | Extension: "tasked-phases", | ||
| @@ -105,7 +130,7 @@ func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) { | |||||
| 105 | if len(plainLines) < 4 { | 130 | if len(plainLines) < 4 { | ||
| 106 | t.Fatalf("checklist rows are missing: %q", plain) | 131 | t.Fatalf("checklist rows are missing: %q", plain) | ||
| 107 | } | 132 | } | ||
| 108 | - | expectedTask := truncateRightBarLine(" [ ] Sort the leftover star stickers", 36) | 133 | + | const expectedTask = " [ ] Sort the leftover star sti..." |
| 109 | if got := strings.TrimRight(plainLines[3], " "); got != expectedTask { | 134 | if got := strings.TrimRight(plainLines[3], " "); got != expectedTask { | ||
| 110 | t.Fatalf("task row = %q, want clipped row %q", got, expectedTask) | 135 | t.Fatalf("task row = %q, want clipped row %q", got, expectedTask) | ||
| 111 | } | 136 | } | ||