From ae0a6ac45ef1899871189147ca29caed5e22957c Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 21:29:43 -0300 Subject: [PATCH] fix(native): draw the Pulls quick filters as ghost tabs that share the list's width The default tabs were the heaviest control on the list island, taller than the search field above them, and with a monospace interface font the last filter ran past the island's edge. The ghost variant, the dock's tabs, now fills a minimum width with equal triggers; the dock lays it out in a Rigid and keeps its own widths. A filter's count fades when it is empty and Failing's turns destructive when a check fails. --- CHANGELOG.md | 4 ++++ native/cmd/lich-native/pulls_list.go | 22 ++++++++++++++++++++-- native/cmd/lich-native/pulls_width_test.go | 15 +++++++++++++++ native/ui/tabs.go | 10 +++++++++- native/ui/tabs_test.go | 21 +++++++++++++++++++++ 5 files changed, 69 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dc86067..d8c9c99c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- The quick filters over the pull request list in Pulls (All, Ready, Drafts, Failing) are lighter: small text with the selected one shaded, as in the dock's tabs, sharing the list's width. A filter with nothing in it is faded and Failing's count turns red when a check fails. They no longer run past the list's edge with a monospace interface font. + ### Fixed - A session card no longer grows and shrinks as its agent starts and stops a tool, waits on you or compacts, pushing every card below it up and down. That line now takes the place of the card's path while it shows, and the path comes back when it goes. diff --git a/native/cmd/lich-native/pulls_list.go b/native/cmd/lich-native/pulls_list.go index 5485cc25..497b78dd 100644 --- a/native/cmd/lich-native/pulls_list.go +++ b/native/cmd/lich-native/pulls_list.go @@ -79,11 +79,29 @@ func (p *pullsScreen) filterTabs(th *ui.Theme, counts [pullsFilterCount]int) ui. triggers := make([]ui.TabsTrigger, pullsFilterCount) for i := range triggers { n := counts[i] + count := filterCountColor(th, pullsFilter(i), n) triggers[i] = ui.TabsTrigger{Text: pullsFilter(i).label(), Trailing: func(gtx C) D { - return th.Text(ui.TextXS, fmt.Sprint(n)).Muted().Layout(gtx) + return th.Text(ui.TextXS, fmt.Sprint(n)).In(count).Layout(gtx) }} } - return th.Tabs(&p.filter, triggers...) + tabs := th.Tabs(&p.filter, triggers...) + tabs.Variant = ui.TabsVariantGhost + return tabs +} + +// emptyFilterOpacity fades a filter with nothing in it, the chip's opacity-40. +const emptyFilterOpacity = 0.4 + +// filterCountColor reads a filter's state off its count: a failing pull +// request in the destructive color, an empty filter faded. +func filterCountColor(th *ui.Theme, f pullsFilter, n int) color.NRGBA { + switch { + case n == 0: + return ui.Alpha(th.MutedForeground, emptyFilterOpacity) + case f == filterFailing: + return th.Destructive + } + return th.MutedForeground } // listWidth is the list island's width: w-80, or what the filter tabs need diff --git a/native/cmd/lich-native/pulls_width_test.go b/native/cmd/lich-native/pulls_width_test.go index b8e4be4a..975a9704 100644 --- a/native/cmd/lich-native/pulls_width_test.go +++ b/native/cmd/lich-native/pulls_width_test.go @@ -33,3 +33,18 @@ func TestPullsListGrowsForLongerFilterLabels(t *testing.T) { t.Errorf("Portuguese: %d, want the %d the tabs need (bounded by %v)", w, need, pullsListMaxWidth) } } + +// The counts carry the filter's state: a failing pull request reads in the +// destructive color, an empty filter fades, the rest stay muted. +func TestFilterCountColorReadsTheState(t *testing.T) { + th := &ui.Theme{Palette: ui.Dark} + if got := filterCountColor(th, filterFailing, 1); got != th.Destructive { + t.Errorf("failing: %v, want destructive", got) + } + if got := filterCountColor(th, filterFailing, 0); got != ui.Alpha(th.MutedForeground, emptyFilterOpacity) { + t.Errorf("failing with none: %v, want faded", got) + } + if got := filterCountColor(th, filterReady, 3); got != th.MutedForeground { + t.Errorf("ready: %v, want muted", got) + } +} diff --git a/native/ui/tabs.go b/native/ui/tabs.go index 8c37acd2..7698d877 100644 --- a/native/ui/tabs.go +++ b/native/ui/tabs.go @@ -199,7 +199,8 @@ func (t TabsStyle) tabsList(gtx C) D { // every trigger as wide as the widest while that fits the width given; a vertical list is w-full over the // same. The ghost list's triggers keep their own width: in the dock's // w-fit list the browser sizes the list to the sum of its triggers' -// contents, so flex-1 has no free space to share. +// contents, so flex-1 has no free space to share. A ghost list given a +// minimum width is w-full instead, and flex-1 shares that. func (t TabsStyle) tabsTriggerSizes(gtx C, geo tabsGeometry) []image.Point { sizes := make([]image.Point, len(t.Triggers)) var widest image.Point @@ -215,6 +216,13 @@ func (t TabsStyle) tabsTriggerSizes(gtx C, geo tabsGeometry) []image.Point { sizes[i].Y = widest.Y } if t.Variant == TabsVariantGhost { + inner := gtx.Constraints.Min.X - (len(sizes)-1)*gtx.Dp(geo.listGap) + if len(sizes)*widest.X <= inner { + for i := range sizes { + sizes[i].X = widest.X + } + } + tabsShareSpare(sizes, inner) return sizes } inner := gtx.Constraints.Max.X - 2*gtx.Dp(geo.listPad) - (len(sizes)-1)*gtx.Dp(geo.listGap) diff --git a/native/ui/tabs_test.go b/native/ui/tabs_test.go index 427678ef..e3335cb6 100644 --- a/native/ui/tabs_test.go +++ b/native/ui/tabs_test.go @@ -276,6 +276,27 @@ func TestTabsGhostTriggersTakeTheirOwnWidth(t *testing.T) { } } +// A ghost list given a minimum width, the Pulls filter's w-full track, splits +// it into equal triggers. +func TestTabsGhostFillsAMinimumWidthEqually(t *testing.T) { + th := testTheme() + s := th.Tabs(new(TabsState), tabsNames("a", "a much longer trigger")...) + s.Variant = TabsVariantGhost + const width = 600 + if d := layoutAt(func(gtx C) D { return s.Layout(gtx) }, width, width); d.Size.X != width { + t.Errorf("got list width %d, want the %d it was given", d.Size.X, width) + } + var sizes []image.Point + layoutAt(func(gtx C) D { + s.State.tabsUpdate(gtx, 2) + sizes = s.tabsTriggerSizes(gtx, tabsGeometries[TabsVariantGhost]) + return D{} + }, width, width) + if sizes[0].X != sizes[1].X { + t.Errorf("trigger widths %d and %d, want them equal", sizes[0].X, sizes[1].X) + } +} + // NaturalWidth is the narrowest list in which no trigger overflows: laid out // at it the list takes exactly that width, one pixel less and it spills. func TestTabsNaturalWidthFitsEveryTriggerAtItsContent(t *testing.T) {