Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 68 additions & 71 deletions internal/tui/workspace/widget/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -373,90 +373,87 @@ func (l *List) View() string {
}

theme := l.styles.Theme()
var b strings.Builder

if l.loading {
return lipgloss.NewStyle().
b.WriteString(lipgloss.NewStyle().
Width(l.width).
Foreground(theme.Muted).
Render("Loading…")
}

var b strings.Builder

// Filter bar
if l.filtering || l.filter != "" {
prefix := lipgloss.NewStyle().Foreground(theme.Primary).Bold(true).Render("/")
filterText := l.filter
cursor := ""
if l.filtering {
cursor = lipgloss.NewStyle().Foreground(theme.Primary).Render("\u2588")
}
counts := lipgloss.NewStyle().Foreground(theme.Muted).
Render(fmt.Sprintf("%d/%d", len(l.filtered), len(l.items)))

countsWidth := lipgloss.Width(counts)
prefixWidth := lipgloss.Width(prefix)
cursorWidth := lipgloss.Width(cursor)
maxFilterWidth := l.width - countsWidth - prefixWidth - cursorWidth - 2
if maxFilterWidth > 0 && lipgloss.Width(filterText) > maxFilterWidth {
filterText = Truncate(filterText, maxFilterWidth)
}
Render("Loading…"))
} else {
// Filter bar
if l.filtering || l.filter != "" {
prefix := lipgloss.NewStyle().Foreground(theme.Primary).Bold(true).Render("/")
filterText := l.filter
cursor := ""
if l.filtering {
cursor = lipgloss.NewStyle().Foreground(theme.Primary).Render("\u2588")
}
counts := lipgloss.NewStyle().Foreground(theme.Muted).
Render(fmt.Sprintf("%d/%d", len(l.filtered), len(l.items)))

countsWidth := lipgloss.Width(counts)
prefixWidth := lipgloss.Width(prefix)
cursorWidth := lipgloss.Width(cursor)
maxFilterWidth := l.width - countsWidth - prefixWidth - cursorWidth - 2
if maxFilterWidth > 0 && lipgloss.Width(filterText) > maxFilterWidth {
filterText = Truncate(filterText, maxFilterWidth)
}

left := prefix + filterText + cursor
leftWidth := lipgloss.Width(left)
gap := l.width - leftWidth - countsWidth
if gap < 1 {
gap = 1
left := prefix + filterText + cursor
leftWidth := lipgloss.Width(left)
gap := l.width - leftWidth - countsWidth
if gap < 1 {
gap = 1
}
b.WriteString(left + strings.Repeat(" ", gap) + counts)
b.WriteString("\n")
}
b.WriteString(left + strings.Repeat(" ", gap) + counts)
b.WriteString("\n")
}

if len(l.filtered) == 0 {
if l.filter != "" {
b.WriteString(lipgloss.NewStyle().
Width(l.width).
Foreground(theme.Muted).
Render("No matches"))
return b.String()
}
if l.emptyMsg != nil {
b.WriteString(l.renderEmptyMessage(theme))
return b.String()
}
b.WriteString(lipgloss.NewStyle().
Width(l.width).
Foreground(theme.Muted).
Render(l.emptyText))
return b.String()
}
if len(l.filtered) == 0 {
if l.filter != "" {
b.WriteString(lipgloss.NewStyle().
Width(l.width).
Foreground(theme.Muted).
Render("No matches"))
} else if l.emptyMsg != nil {
b.WriteString(l.renderEmptyMessage(theme))
} else {
b.WriteString(lipgloss.NewStyle().
Width(l.width).
Foreground(theme.Muted).
Render(l.emptyText))
}
} else {
visibleHeight := l.visibleHeight()
end := l.offset + visibleHeight
if end > len(l.filtered) {
end = len(l.filtered)
}

visibleHeight := l.visibleHeight()
end := l.offset + visibleHeight
if end > len(l.filtered) {
end = len(l.filtered)
}
for i := l.offset; i < end; i++ {
item := l.filtered[i]
isSelected := i == l.cursor && l.focused

for i := l.offset; i < end; i++ {
item := l.filtered[i]
isSelected := i == l.cursor && l.focused
line := l.renderItem(item, isSelected, theme)
b.WriteString(line)
if i < end-1 {
b.WriteString("\n")
}
}

line := l.renderItem(item, isSelected, theme)
b.WriteString(line)
if i < end-1 {
b.WriteString("\n")
// Scroll indicator
if len(l.filtered) > visibleHeight {
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().
Foreground(theme.Muted).
Render(fmt.Sprintf(" %d/%d", l.cursor+1, len(l.filtered))))
}
}
}

// Scroll indicator
if len(l.filtered) > visibleHeight {
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().
Foreground(theme.Muted).
Render(fmt.Sprintf(" %d/%d", l.cursor+1, len(l.filtered))))
}

return b.String()
// Pad output to allocated height to prevent content area collapse
return lipgloss.NewStyle().Width(l.width).Height(l.height).Render(b.String())
}

func (l *List) renderEmptyMessage(theme tui.Theme) string {
Expand Down
53 changes: 53 additions & 0 deletions internal/tui/workspace/widget/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,59 @@ func TestList_DescriptionWithExtra_NarrowWidth(t *testing.T) {
assert.Contains(t, view, "Badge", "Extra should render even at narrow width")
}

func TestList_HeightPadding_FewItems(t *testing.T) {
l := NewList(tui.NewStyles())
l.SetSize(60, 10)
l.SetFocused(true)
l.SetItems(sampleItems(2)) // only 2 items in a 10-line viewport

view := l.View()
lines := strings.Split(view, "\n")
assert.Equal(t, 10, len(lines), "output should fill allocated height even with few items")
}

func TestList_HeightPadding_Empty(t *testing.T) {
l := NewList(tui.NewStyles())
l.SetSize(60, 10)
l.SetFocused(true)
l.SetEmptyText("Nothing here")
l.SetItems(nil)

view := l.View()
lines := strings.Split(view, "\n")
assert.Equal(t, 10, len(lines), "empty list should fill allocated height")
assert.Contains(t, view, "Nothing here")
}

func TestList_HeightPadding_Loading(t *testing.T) {
l := NewList(tui.NewStyles())
l.SetSize(60, 10)
l.SetFocused(true)
l.SetLoading(true)

view := l.View()
lines := strings.Split(view, "\n")
assert.Equal(t, 10, len(lines), "loading state should fill allocated height")
assert.Contains(t, view, "Loading")
}

func TestList_HeightPadding_NoMatchesFilter(t *testing.T) {
l := NewList(tui.NewStyles())
l.SetSize(60, 10)
l.SetFocused(true)
l.SetItems(sampleItems(5))
l.StartFilter()
// Type something that matches nothing
for _, r := range "zzzzz" {
l.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
}

view := l.View()
lines := strings.Split(view, "\n")
assert.Equal(t, 10, len(lines), "no-matches filter should fill allocated height")
assert.Contains(t, view, "No matches")
}

func TestList_LongFilter_NoOverflow(t *testing.T) {
l := NewList(tui.NewStyles())
l.SetSize(40, 20)
Expand Down
Loading