From 91f39ea11df2e4936e8ff903cd0ee17dbaae6270 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sun, 14 Dec 2025 17:16:59 +0100 Subject: [PATCH] chore: add focus aware items --- 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(-) diff --git a/go.mod b/go.mod index 6f81194bf78d10e1c41c372cf5e1f294f9c2441f..ab7e28f9d60de4503a71617a0f07c3d05b8e6150 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 7b3d8cfd5fc76a63ed0c105e9a5dba9972031135..7303674b5109265dba5a327c1bb7776beb2ed146 100644 --- a/go.sum +++ b/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= diff --git a/internal/ui/common/markdown.go b/internal/ui/common/markdown.go index 3c90c2dc1582160c919f4fe432e78642a0a2c97d..a92e70c689530daa36a6147d1c52d2875e556a52 100644 --- a/internal/ui/common/markdown.go +++ b/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 diff --git a/internal/ui/dialog/items.go b/internal/ui/dialog/items.go index eb0bfc727d3322f0e928d36069b9d483fc130df5..5caeec712c21a5e73c7250bc783db61eff0b3e0c 100644 --- a/internal/ui/dialog/items.go +++ b/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 diff --git a/internal/ui/list/item.go b/internal/ui/list/item.go index 48d53b75d057d40f76bf2b16ce2060601c1222f5..279c65b03daf7f9b306bca6d16107957211d7c8a 100644 --- a/internal/ui/list/item.go +++ b/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) +} diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 92585c208f16d91d5c8c3e9f7d8f5f28c4721f9c..d33b3669fd3e70a966101a4e1a5240a5e197e32a 100644 --- a/internal/ui/list/list.go +++ b/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.