chore: add focus aware items

Kujtim Hoxha created

Change summary

go.mod                         | 18 ++++++------
go.sum                         | 18 ++++++++++++
internal/ui/common/markdown.go |  4 +-
internal/ui/dialog/items.go    | 26 ++++++++++++------
internal/ui/list/item.go       |  7 +++++
internal/ui/list/list.go       | 50 ++++++++++++++++++++++++++++++++++++
6 files changed, 103 insertions(+), 20 deletions(-)

Detailed changes

go.mod 🔗

@@ -26,7 +26,7 @@ require (
 	github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
 	github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
 	github.com/charmbracelet/x/exp/ordered v0.1.0
-	github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
+	github.com/charmbracelet/x/exp/slice v0.0.0-20251212161403-a3028fabe6bc
 	github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
 	github.com/charmbracelet/x/term v0.2.2
 	github.com/denisbrodbeck/machineid v1.0.1
@@ -56,9 +56,9 @@ require (
 	github.com/tidwall/sjson v1.2.5
 	github.com/zeebo/xxh3 v1.0.2
 	golang.org/x/mod v0.30.0
-	golang.org/x/net v0.47.0
-	golang.org/x/sync v0.18.0
-	golang.org/x/text v0.31.0
+	golang.org/x/net v0.48.0
+	golang.org/x/sync v0.19.0
+	golang.org/x/text v0.32.0
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 	mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
 	mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5
@@ -153,8 +153,8 @@ require (
 	github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
-	github.com/yuin/goldmark v1.7.8 // indirect
-	github.com/yuin/goldmark-emoji v1.0.5 // indirect
+	github.com/yuin/goldmark v1.7.13 // indirect
+	github.com/yuin/goldmark-emoji v1.0.6 // indirect
 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
@@ -163,12 +163,12 @@ require (
 	go.opentelemetry.io/otel/trace v1.37.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
-	golang.org/x/crypto v0.45.0 // indirect
+	golang.org/x/crypto v0.46.0 // indirect
 	golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
 	golang.org/x/image v0.27.0 // indirect
 	golang.org/x/oauth2 v0.33.0 // indirect
-	golang.org/x/sys v0.38.0 // indirect
-	golang.org/x/term v0.37.0 // indirect
+	golang.org/x/sys v0.39.0 // indirect
+	golang.org/x/term v0.38.0 // indirect
 	golang.org/x/time v0.12.0 // indirect
 	google.golang.org/api v0.239.0 // indirect
 	google.golang.org/genai v1.37.0 // indirect

go.sum 🔗

@@ -112,6 +112,8 @@ github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sB
 github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff h1:Uwr+/JS+qnRcO/++xjYEDtW7x+P5E4+4cBiOHTt2Xfk=
 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
+github.com/charmbracelet/x/exp/slice v0.0.0-20251212161403-a3028fabe6bc h1:NNtbWezz9vYKrWbOwfQ3Vxtj6Feysb33wdpSV8x13ck=
+github.com/charmbracelet/x/exp/slice v0.0.0-20251212161403-a3028fabe6bc/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
 github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
 github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
 github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
@@ -336,8 +338,12 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
 github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
 github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
 github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
+github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
 github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
 github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
+github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
+github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
 github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
 github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
 github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
@@ -372,6 +378,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
 golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
 golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
 golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
+golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
 golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
@@ -396,6 +404,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
 golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
 golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
+golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
 golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
 golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -407,6 +417,8 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
 golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -425,6 +437,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
 golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
+golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -438,6 +452,8 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
 golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
 golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
 golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
+golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
+golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -449,6 +465,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
 golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
 golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

internal/ui/common/markdown.go 🔗

@@ -2,8 +2,8 @@ package common
 
 import (
 	"github.com/charmbracelet/crush/internal/ui/styles"
-	"github.com/charmbracelet/glamour/v2"
-	gstyles "github.com/charmbracelet/glamour/v2/styles"
+	"charm.land/glamour/v2"
+	gstyles "charm.land/glamour/v2/styles"
 )
 
 // MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with

internal/ui/dialog/items.go 🔗

@@ -27,15 +27,16 @@ type ListItem interface {
 // SessionItem wraps a [session.Session] to implement the [ListItem] interface.
 type SessionItem struct {
 	session.Session
-	t *styles.Styles
-	m fuzzy.Match
+	t       *styles.Styles
+	m       fuzzy.Match
+	focused bool
 }
 
 var _ ListItem = &SessionItem{}
 
 // Filter returns the filterable value of the session.
 func (s *SessionItem) Filter() string {
-	return s.Session.Title
+	return s.Title
 }
 
 // ID returns the unique identifier of the session.
@@ -48,16 +49,23 @@ func (s *SessionItem) SetMatch(m fuzzy.Match) {
 	s.m = m
 }
 
+// SetFocused set the current items focus state
+func (s *SessionItem) SetFocused(focused bool) {
+	s.focused = focused
+}
+
 // Render returns the string representation of the session item.
 func (s *SessionItem) Render(width int) string {
-	age := humanize.Time(time.Unix(s.Session.UpdatedAt, 0))
-	age = s.t.Subtle.Render(age)
-	age = " " + age
-	ageLen := lipgloss.Width(age)
-	title := s.Session.Title
+	lastUpdated := humanize.Time(time.Unix(s.UpdatedAt, 0))
+	if !s.focused {
+		lastUpdated = s.t.Subtle.Render(lastUpdated)
+	}
+	lastUpdated = " " + lastUpdated
+	ageLen := lipgloss.Width(lastUpdated)
+	title := s.Title
 	titleLen := lipgloss.Width(title)
 	title = ansi.Truncate(title, max(0, width-ageLen), "…")
-	right := lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age)
+	right := lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(lastUpdated)
 
 	if matches := len(s.m.MatchedIndexes); matches > 0 {
 		var lastPos int

internal/ui/list/item.go 🔗

@@ -32,3 +32,10 @@ type MouseClickable interface {
 	// It returns true if the event was handled, false otherwise.
 	HandleMouseClick(btn ansi.MouseButton, x, y int) bool
 }
+
+// FocusAware represents an item that needs to know its focus state for
+// internal rendering decisions.
+type FocusAware interface {
+	// SetFocused is called before Render to inform the item of its focus state.
+	SetFocused(focused bool)
+}

internal/ui/list/list.go 🔗

@@ -158,6 +158,12 @@ func (l *List) renderItem(idx int, process bool) renderedItem {
 		}
 	}
 
+	// Notify item of focus state if it cares.
+	isFocused := l.focused && idx == l.selectedIdx
+	if focusAware, ok := l.items[idx].(FocusAware); ok {
+		focusAware.SetFocused(isFocused)
+	}
+
 	ri, ok := l.renderedItems[idx]
 	if !ok {
 		item := l.items[idx]
@@ -413,11 +419,23 @@ func (l *List) AppendItems(items ...Item) {
 // Focus sets the focus state of the list.
 func (l *List) Focus() {
 	l.focused = true
+	// Invalidate the selected item if it's focus-aware.
+	if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
+		if _, ok := l.items[l.selectedIdx].(FocusAware); ok {
+			l.invalidateItem(l.selectedIdx)
+		}
+	}
 }
 
 // Blur removes the focus state from the list.
 func (l *List) Blur() {
 	l.focused = false
+	// Invalidate the selected item if it's focus-aware.
+	if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
+		if _, ok := l.items[l.selectedIdx].(FocusAware); ok {
+			l.invalidateItem(l.selectedIdx)
+		}
+	}
 }
 
 // ScrollToTop scrolls the list to the top.
@@ -497,38 +515,66 @@ func (l *List) SelectedItemInView() bool {
 
 // SetSelected sets the selected item index in the list.
 func (l *List) SetSelected(index int) {
+	oldIdx := l.selectedIdx
 	if index < 0 || index >= len(l.items) {
 		l.selectedIdx = -1
 	} else {
 		l.selectedIdx = index
 	}
+	l.invalidateFocusAwareItems(oldIdx, l.selectedIdx)
+}
+
+// invalidateFocusAwareItems invalidates the cache for items that implement
+// FocusAware when their focus state changes.
+func (l *List) invalidateFocusAwareItems(oldIdx, newIdx int) {
+	if oldIdx == newIdx {
+		return
+	}
+	if oldIdx >= 0 && oldIdx < len(l.items) {
+		if _, ok := l.items[oldIdx].(FocusAware); ok {
+			l.invalidateItem(oldIdx)
+		}
+	}
+	if newIdx >= 0 && newIdx < len(l.items) {
+		if _, ok := l.items[newIdx].(FocusAware); ok {
+			l.invalidateItem(newIdx)
+		}
+	}
 }
 
 // SelectPrev selects the previous item in the list.
 func (l *List) SelectPrev() {
 	if l.selectedIdx > 0 {
+		oldIdx := l.selectedIdx
 		l.selectedIdx--
+		l.invalidateFocusAwareItems(oldIdx, l.selectedIdx)
 	}
 }
 
 // SelectNext selects the next item in the list.
 func (l *List) SelectNext() {
 	if l.selectedIdx < len(l.items)-1 {
+		oldIdx := l.selectedIdx
 		l.selectedIdx++
+		l.invalidateFocusAwareItems(oldIdx, l.selectedIdx)
 	}
 }
 
 // SelectFirst selects the first item in the list.
 func (l *List) SelectFirst() {
 	if len(l.items) > 0 {
+		oldIdx := l.selectedIdx
 		l.selectedIdx = 0
+		l.invalidateFocusAwareItems(oldIdx, l.selectedIdx)
 	}
 }
 
 // SelectLast selects the last item in the list.
 func (l *List) SelectLast() {
 	if len(l.items) > 0 {
+		oldIdx := l.selectedIdx
 		l.selectedIdx = len(l.items) - 1
+		l.invalidateFocusAwareItems(oldIdx, l.selectedIdx)
 	}
 }
 
@@ -544,13 +590,17 @@ func (l *List) SelectedItem() Item {
 // SelectFirstInView selects the first item currently in view.
 func (l *List) SelectFirstInView() {
 	startIdx, _ := l.findVisibleItems()
+	oldIdx := l.selectedIdx
 	l.selectedIdx = startIdx
+	l.invalidateFocusAwareItems(oldIdx, l.selectedIdx)
 }
 
 // SelectLastInView selects the last item currently in view.
 func (l *List) SelectLastInView() {
 	_, endIdx := l.findVisibleItems()
+	oldIdx := l.selectedIdx
 	l.selectedIdx = endIdx
+	l.invalidateFocusAwareItems(oldIdx, l.selectedIdx)
 }
 
 // HandleMouseDown handles mouse down events at the given line in the viewport.