From 50473774c3f428c6ec1c3ae221dde3f0e281fe2c Mon Sep 17 00:00:00 2001 From: Eugene Date: Sun, 14 Jun 2026 21:32:49 +0300 Subject: [PATCH] feat(search): highlight and jump between values matches instead of filtering --- service/values_service.go | 43 ++--- ui/app.go | 5 + ui/page/values_controller.go | 1 + ui/page/values_page.go | 65 +++---- ui/page/values_page_events.go | 5 + ui/page/values_page_search.go | 289 +++++++++++++++++++++++++++++ ui/page/values_page_search_test.go | 49 +++++ ui/state/values_state.go | 24 ++- ui/widget/label.go | 16 +- ui/widget/override_table.go | 106 ++++++++--- ui/widget/search_bar.go | 53 ++++-- 11 files changed, 549 insertions(+), 107 deletions(-) create mode 100644 ui/page/values_page_search.go create mode 100644 ui/page/values_page_search_test.go diff --git a/service/values_service.go b/service/values_service.go index 5ef1c72..bcf2085 100644 --- a/service/values_service.go +++ b/service/values_service.go @@ -501,40 +501,33 @@ func (s *ValuesService) CompareWithBaseline( return changes, nil } -// ApplyCollapseFilter removes entries whose key has any collapsed ancestor, -// i.e. the entry sits inside a user-collapsed section. The collapsed section -// header itself is kept visible so the user can click its chevron to expand -// again. Reuses `out` (truncated) to avoid per-frame allocation; `out` and -// `indices` may safely alias — the write pointer never overtakes the read -// pointer. When `collapsed` is empty the function short-circuits and returns -// `indices` unchanged (no copy into `out`). -func ApplyCollapseFilter( - entries []FlatValueEntry, - indices []int, - collapsed map[string]bool, - out []int, -) []int { +// VisibleRows returns the entry indices that render as table rows: every entry +// except those hidden inside a user-collapsed section (the collapsed section +// header itself stays visible so its chevron can re-expand it). When `collapsed` +// is empty this is simply 0..len(entries)-1. Reuses `out` (truncated) to avoid +// per-frame allocation. +func VisibleRows(entries []FlatValueEntry, collapsed map[string]bool, out []int) []int { + out = out[:0] + if len(collapsed) == 0 { - return indices - } + for i := range entries { + out = append(out, i) + } - out = out[:0] + return out + } // Comment rows have no flat key of their own — visibility piggy-backs on the // most recently seen non-comment row so a foot comment under a collapsed // section disappears with that section. lastNonCommentVisible := true - for _, idx := range indices { - if idx >= len(entries) { - continue - } - - entry := entries[idx] + for i := range entries { + entry := entries[i] if entry.IsComment() { if lastNonCommentVisible { - out = append(out, idx) + out = append(out, i) } continue @@ -546,7 +539,7 @@ func ApplyCollapseFilter( if collapsed[key] { lastNonCommentVisible = true - out = append(out, idx) + out = append(out, i) continue } @@ -564,7 +557,7 @@ func ApplyCollapseFilter( lastNonCommentVisible = !hidden if !hidden { - out = append(out, idx) + out = append(out, i) } } diff --git a/ui/app.go b/ui/app.go index 581397e..9ab5df5 100644 --- a/ui/app.go +++ b/ui/app.go @@ -670,6 +670,11 @@ func (a *Application) handleKeyEvents(gtx layout.Context) { a.valuesState.AnchorOpName = "" case a.valuesState.AnchorMenuOpen: a.valuesState.AnchorMenuOpen = false + case a.navState.CurrentPage == state.PageValues && a.valuesState.SearchFocused(gtx): + // Escape clears a non-empty search field rather than leaving the page. + a.valuesState.SearchEditor.SetText("") + gtx.Execute(key.FocusCmd{Tag: &a.valuesState.SearchEditor}) + gtx.Execute(op.InvalidateCmd{}) default: a.navigateBack() } diff --git a/ui/page/values_controller.go b/ui/page/values_controller.go index ff30b5d..93327f5 100644 --- a/ui/page/values_controller.go +++ b/ui/page/values_controller.go @@ -571,6 +571,7 @@ func (vc *ValuesController) ResetState() { vc.State.CollapsedPreSearch = nil vc.State.SearchCollapseActive = false vc.State.SearchEditor.SetText("") + vc.State.CurrentMatch = -1 vc.State.OverrideList.Position.First = 0 vc.State.OverrideList.Position.Offset = 0 diff --git a/ui/page/values_page.go b/ui/page/values_page.go index f2bf429..acf29be 100644 --- a/ui/page/values_page.go +++ b/ui/page/values_page.go @@ -216,6 +216,9 @@ type ValuesPage struct { lastFocusedRow int lastFocusedCol int + // lastSearchQuery caches the previous frame's query + lastSearchQuery string + // AnchorDialog and AnchorMenu are the widget instances driving the // anchor/alias mutation UI. They live on the page (not state) so state // types don't need to import ui/widget. The page's Layout wires them to @@ -245,14 +248,17 @@ type ValuesPage struct { func NewValuesPage(th *material.Theme, st *state.ValuesPageState, cb ValuesPageCallbacks) *ValuesPage { st.SearchEditor.SingleLine = true + st.SearchEditor.Submit = true // Enter jumps to the next match + st.CurrentMatch = -1 p := &ValuesPage{ Theme: th, State: st, Table: customwidget.OverrideTable{ - Theme: th, - List: &st.OverrideList, - HoveredRow: -1, + Theme: th, + List: &st.OverrideList, + HoveredRow: -1, + CurrentMatchEntry: -1, }, Search: customwidget.SearchBar{Editor: &st.SearchEditor}, ValuesPageCallbacks: cb, @@ -374,54 +380,45 @@ func (p *ValuesPage) Layout(gtx layout.Context) layout.Dimensions { p.Table.OnCellNullify = p.OnCellNullify p.Table.SearchQuery = p.Search.Editor.Text() - // Build columnEditors slice for search filter. + // Build columnEditors slice for the search match scan. for c := range p.State.ColumnCount { p.columnEditorSlices[c] = p.State.Columns[c].OverrideEditors } - // Recompute filtered indices. - p.State.FilteredIndices = p.Search.FilterEntriesWithMultiOverrides( + // Search highlights and jumps rather than filtering: SearchMatches holds the + // matching entry indices; FilteredIndices (every visible row, built below + // after any jump-induced uncollapse) stays independent of the query. + p.State.SearchMatches = p.Search.MatchingEntries( p.State.Entries, p.columnEditorSlices[:p.State.ColumnCount], - p.State.FilteredIndices, + p.State.SearchMatches, ) // Snapshot the user's collapsed set when search becomes active, so that // any search-induced auto-uncollapse below can be reverted when the user // clears the search. Restore on the reverse transition. inSearch := p.Search.Editor.Text() != "" - wasActive := p.State.SearchCollapseActive p.syncSearchCollapseSnapshot(inSearch) - // Reset scroll to the top on the empty→non-empty search transition so the - // first match is visible instead of hidden below the prior scroll offset. - if !wasActive && p.State.SearchCollapseActive { - p.State.OverrideList.Position.First = 0 - p.State.OverrideList.Position.Offset = 0 - } - - // Auto-expand any collapsed ancestors of search matches so results are - // never hidden. Mutates CollapsedKeys only — CollapsedPreSearch retains - // the user's intent and is what the controller persists while // SearchCollapseActive is true, so nothing is lost on search clear. if inSearch && len(p.State.CollapsedKeys) > 0 { service.UncollapseMatchAncestors( p.State.Entries, - p.State.FilteredIndices, + p.State.SearchMatches, p.State.CollapsedKeys, ) } - // Hide entries inside collapsed sections. Skipped during an active search - // so matches inside (previously) collapsed sections stay visible. - if !inSearch && len(p.State.CollapsedKeys) > 0 { - p.State.FilteredIndices = service.ApplyCollapseFilter( - p.State.Entries, - p.State.FilteredIndices, - p.State.CollapsedKeys, - p.State.FilteredIndices, - ) - } + p.handleSearchNavigation(gtx) + + // Build the visible-row list (all entries minus collapsed sections). Done + // after navigation so a jump's uncollapse of its target's ancestors is + // reflected this frame. + p.State.FilteredIndices = service.VisibleRows( + p.State.Entries, + p.State.CollapsedKeys, + p.State.FilteredIndices, + ) // Resolve a pending restored focus key to a filtered row. If the key is no // longer visible (filtered out or removed from the chart), drop the @@ -480,6 +477,8 @@ func (p *ValuesPage) Layout(gtx layout.Context) layout.Dimensions { p.Table.FocusedRow = p.State.FocusedRow p.Table.FocusedCol = p.State.FocusedCol + p.Table.CurrentMatchEntry = p.currentMatchEntry() + p.Table.SearchMatches = p.State.SearchMatches // When the controller signals a pending focus highlight (fires once per // chart load after the async UI-state load completes), focus the @@ -518,13 +517,15 @@ func (p *ValuesPage) Layout(gtx layout.Context) layout.Dimensions { editors := p.State.Columns[p.State.FocusedCol].OverrideEditors if entryIdx < len(editors) { - searchBusy := gtx.Focused(p.Search.Editor) && p.Search.Editor.Text() != "" + // Don't steal focus into the cell while the user is working the + // search bar (field or a nav button) + searchBusy := p.State.SearchFocused(gtx) switch { case gtx.Focused(&editors[entryIdx]): // Focus landed — stop retrying. case searchBusy: - // User is actively typing in search; don't steal focus. + // User is actively working search; don't steal focus. default: gtx.Execute(key.FocusCmd{Tag: &editors[entryIdx]}) gtx.Execute(op.InvalidateCmd{}) @@ -583,7 +584,7 @@ func (p *ValuesPage) Layout(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions { searchHint := platform.ShortcutLabel("\u2318+F", "Ctrl+F") - dims := p.Search.Layout(gtx, p.Theme, "Search values... ("+searchHint+")") + dims := p.Search.Layout(gtx, p.Theme, "Search values... ("+searchHint+")", p.layoutSearchNav) totalRigidH += dims.Size.Y diff --git a/ui/page/values_page_events.go b/ui/page/values_page_events.go index 998a34e..52b27b5 100644 --- a/ui/page/values_page_events.go +++ b/ui/page/values_page_events.go @@ -189,6 +189,11 @@ func collapsedEqual(a, b map[string]bool) bool { } func (p *ValuesPage) handleKeyEvents(gtx layout.Context) { + // Intercept the search field's Ctrl/Cmd word chords before the editor's own + // Update runs later this frame (a consumed key event is removed from the + // queue), working around Gio swallowing them on Windows/Linux. + p.handleSearchEditorKeys(gtx) + area := clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops) event.Op(gtx.Ops, p) area.Pop() diff --git a/ui/page/values_page_search.go b/ui/page/values_page_search.go new file mode 100644 index 0000000..b1e557f --- /dev/null +++ b/ui/page/values_page_search.go @@ -0,0 +1,289 @@ +package page + +import ( + "strconv" + "unicode" + + "gioui.org/io/key" + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + + "github.com/qdeck-app/qdeck/ui/theme" + customwidget "github.com/qdeck-app/qdeck/ui/widget" +) + +// handleSearchNavigation steps the active match cursor for the prev/next buttons, +// Enter, and query edits (a query change snaps to the first hit). +func (p *ValuesPage) handleSearchNavigation(gtx layout.Context) { + // Enter in the search field jumps to the next match. + for { + ev, ok := p.Search.Editor.Update(gtx) + if !ok { + break + } + + if _, isSubmit := ev.(widget.SubmitEvent); isSubmit { + p.navigateMatch(gtx, 1) + } + } + + // A click grabs focus onto the button, so pull it back to the field to keep typing and keep the jump search-driven. + if p.State.SearchPrevButton.Clicked(gtx) { + p.navigateMatch(gtx, -1) + gtx.Execute(key.FocusCmd{Tag: p.Search.Editor}) + } + + if p.State.SearchNextButton.Clicked(gtx) { + p.navigateMatch(gtx, 1) + gtx.Execute(key.FocusCmd{Tag: p.Search.Editor}) + } + + // On a query change, land on the first match (or clear the cursor) once per edit. + query := p.Search.Editor.Text() + if query != p.lastSearchQuery { + p.lastSearchQuery = query + + if len(p.State.SearchMatches) > 0 { + p.State.CurrentMatch = 0 + p.scrollToCurrentMatch(gtx) + } else { + p.State.CurrentMatch = -1 + } + + return + } + + // Re-anchor the cursor when the match set changes under a stable query: -1 with no matches, else clamped to [0, n-1]. + switch n := len(p.State.SearchMatches); { + case n == 0: + p.State.CurrentMatch = -1 + case p.State.CurrentMatch < 0: + p.State.CurrentMatch = 0 + case p.State.CurrentMatch >= n: + p.State.CurrentMatch = n - 1 + } +} + +// navigateMatch advances the cursor by delta with wraparound and scrolls the match +// into view (a parked -1 starts at the first match forward, the last backward). +func (p *ValuesPage) navigateMatch(gtx layout.Context, delta int) { + n := len(p.State.SearchMatches) + if n == 0 { + p.State.CurrentMatch = -1 + + return + } + + switch { + case p.State.CurrentMatch < 0 && delta > 0: + p.State.CurrentMatch = 0 + case p.State.CurrentMatch < 0: + p.State.CurrentMatch = n - 1 + default: + p.State.CurrentMatch = (p.State.CurrentMatch + delta + n) % n + } + + p.scrollToCurrentMatch(gtx) +} + +// scrollToCurrentMatch reuses the flat-key jump machinery to uncollapse, scroll to, and highlight the entry under the active match cursor. +func (p *ValuesPage) scrollToCurrentMatch(gtx layout.Context) { + entryIdx := p.currentMatchEntry() + if entryIdx < 0 || entryIdx >= len(p.State.Entries) { + return + } + + p.jumpToFlatKey(gtx, p.State.Entries[entryIdx].Key) +} + +// handleSearchEditorKeys applies Ctrl/Cmd word-wise caret movement, deletion, and +// Shift+Enter to the focused search field, polled before the editor's Update since +// Gio swallows these chords on Windows/Linux where ModShortcut == ModShortcutAlt. +func (p *ValuesPage) handleSearchEditorKeys(gtx layout.Context) { + ed := p.Search.Editor + + for { + ev, ok := gtx.Event( + key.Filter{Focus: ed, Name: key.NameDeleteBackward, Required: key.ModShortcut, Optional: key.ModShift}, + key.Filter{Focus: ed, Name: key.NameDeleteForward, Required: key.ModShortcut, Optional: key.ModShift}, + key.Filter{Focus: ed, Name: key.NameLeftArrow, Required: key.ModShortcut, Optional: key.ModShift}, + key.Filter{Focus: ed, Name: key.NameRightArrow, Required: key.ModShortcut, Optional: key.ModShift}, + // Shift+Enter steps to the previous match (plain Enter → next comes via the editor's SubmitEvent). + key.Filter{Focus: ed, Name: key.NameReturn, Required: key.ModShift}, + key.Filter{Focus: ed, Name: key.NameEnter, Required: key.ModShift}, + ) + if !ok { + break + } + + e, isKey := ev.(key.Event) + if !isKey || e.State != key.Press { + continue + } + + extend := e.Modifiers.Contain(key.ModShift) + + switch e.Name { + case key.NameLeftArrow: + p.searchMoveWord(-1, extend) + case key.NameRightArrow: + p.searchMoveWord(1, extend) + case key.NameDeleteBackward: + p.searchDeleteWord(-1) + case key.NameDeleteForward: + p.searchDeleteWord(1) + case key.NameReturn, key.NameEnter: + p.navigateMatch(gtx, -1) + } + } +} + +// searchMoveWord moves the search caret one word in dir (-1 back, +1 forward), extending the selection from the anchor when extend is set. +func (p *ValuesPage) searchMoveWord(dir int, extend bool) { + ed := p.Search.Editor + caret, anchor := ed.Selection() + + target := wordBoundary([]rune(ed.Text()), caret, dir) + if extend { + ed.SetCaret(target, anchor) + } else { + ed.SetCaret(target, target) + } +} + +// searchDeleteWord deletes one word from the search caret in dir (-1 back, +1 forward), or the current selection if there is one. +func (p *ValuesPage) searchDeleteWord(dir int) { + ed := p.Search.Editor + + caret, anchor := ed.Selection() + if caret != anchor { + ed.Insert("") + + return + } + + target := wordBoundary([]rune(ed.Text()), caret, dir) + if target == caret { + return + } + + ed.SetCaret(caret, target) + ed.Insert("") +} + +// wordBoundary returns the word-boundary offset from pos in dir (-1 back, +1 forward). +func wordBoundary(runes []rune, pos, dir int) int { + if dir < 0 { + return prevWordBoundary(runes, pos) + } + + return nextWordBoundary(runes, pos) +} + +// isWordRune reports whether r is part of a word (letter, digit, or underscore); everything else splits words. +func isWordRune(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' +} + +// prevWordBoundary returns the rune offset of the start of the word preceding pos, skipping a leading run of spaces first. +func prevWordBoundary(runes []rune, pos int) int { + i := pos + for i > 0 && unicode.IsSpace(runes[i-1]) { + i-- + } + + if i == 0 { + return i + } + + if isWordRune(runes[i-1]) { + for i > 0 && isWordRune(runes[i-1]) { + i-- + } + } else { + for i > 0 && !isWordRune(runes[i-1]) && !unicode.IsSpace(runes[i-1]) { + i-- + } + } + + return i +} + +// nextWordBoundary returns the rune offset of the end of the word following pos, mirroring prevWordBoundary forward. +func nextWordBoundary(runes []rune, pos int) int { + i := pos + for i < len(runes) && unicode.IsSpace(runes[i]) { + i++ + } + + if i == len(runes) { + return i + } + + if isWordRune(runes[i]) { + for i < len(runes) && isWordRune(runes[i]) { + i++ + } + } else { + for i < len(runes) && !isWordRune(runes[i]) && !unicode.IsSpace(runes[i]) { + i++ + } + } + + return i +} + +// currentMatchEntry returns the entry index of the active match, or -1 when the cursor is parked or there are no matches. +func (p *ValuesPage) currentMatchEntry() int { + if p.State.CurrentMatch < 0 || p.State.CurrentMatch >= len(p.State.SearchMatches) { + return -1 + } + + return p.State.SearchMatches[p.State.CurrentMatch] +} + +// layoutSearchNav renders the trailing match counter and prev/next jump buttons, or nothing when the query is empty. +func (p *ValuesPage) layoutSearchNav(gtx layout.Context) layout.Dimensions { + if p.Search.Editor.Text() == "" { + return layout.Dimensions{} + } + + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(p.layoutSearchCounter), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return p.layoutSearchNavButton(gtx, &p.State.SearchPrevButton, "↑") + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return p.layoutSearchNavButton(gtx, &p.State.SearchNextButton, "↓") + }), + ) +} + +// layoutSearchCounter renders the "current / total" match readout, or "No results" in the danger color when there are none. +func (p *ValuesPage) layoutSearchCounter(gtx layout.Context) layout.Dimensions { + n := len(p.State.SearchMatches) + + txt := strconv.Itoa(p.State.CurrentMatch+1) + " / " + strconv.Itoa(n) + txtColor := theme.Default.Muted + + if n == 0 { + txt = "No results" + txtColor = theme.Default.Danger + } + + lbl := material.Caption(p.Theme, txt) + lbl.Color = txtColor + + return layout.Inset{Right: textBtnPaddingH}.Layout(gtx, customwidget.LabelWidget(lbl)) +} + +// layoutSearchNavButton renders one compact prev/next jump button with horizontal-only padding so the search bar keeps the editor's line height. +func (p *ValuesPage) layoutSearchNavButton(gtx layout.Context, click *widget.Clickable, glyph string) layout.Dimensions { + return layoutClickablePointer(gtx, click, func(gtx layout.Context) layout.Dimensions { + lbl := material.Body2(p.Theme, glyph) + lbl.Color = theme.Default.Override + + return layout.Inset{Left: textBtnPaddingH, Right: textBtnPaddingH}.Layout(gtx, customwidget.LabelWidget(lbl)) + }) +} diff --git a/ui/page/values_page_search_test.go b/ui/page/values_page_search_test.go new file mode 100644 index 0000000..158ca28 --- /dev/null +++ b/ui/page/values_page_search_test.go @@ -0,0 +1,49 @@ +package page + +import "testing" + +func TestWordBoundaries(t *testing.T) { + const ( + sample = "hello world" + dotted = "foo.bar" + spaced = "foo " + underscored = "a_b cd" + ) + + cases := []struct { + name string + fn func([]rune, int) int + text string + pos int + want int + }{ + {"prev/empty", prevWordBoundary, "", 0, 0}, + {"prev/at start", prevWordBoundary, sample, 0, 0}, + {"prev/end skips last word", prevWordBoundary, sample, 11, 6}, + {"prev/mid word to word start", prevWordBoundary, sample, 9, 6}, + {"prev/word start crosses space+word", prevWordBoundary, sample, 6, 0}, + {"prev/leading spaces to start", prevWordBoundary, " foo", 2, 0}, + {"prev/trailing spaces then word", prevWordBoundary, spaced, 6, 0}, + {"prev/punctuation run", prevWordBoundary, dotted, 7, 4}, + {"prev/stops at punctuation", prevWordBoundary, dotted, 4, 3}, + {"prev/underscore is word rune", prevWordBoundary, underscored, 3, 0}, + + {"next/empty", nextWordBoundary, "", 0, 0}, + {"next/at end", nextWordBoundary, sample, 11, 11}, + {"next/start skips first word", nextWordBoundary, sample, 0, 5}, + {"next/mid word to word end", nextWordBoundary, sample, 2, 5}, + {"next/space crosses space+word", nextWordBoundary, sample, 5, 11}, + {"next/trailing spaces to end", nextWordBoundary, spaced, 3, 6}, + {"next/punctuation run", nextWordBoundary, dotted, 0, 3}, + {"next/stops at punctuation", nextWordBoundary, dotted, 3, 4}, + {"next/underscore is word rune", nextWordBoundary, underscored, 0, 3}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := c.fn([]rune(c.text), c.pos); got != c.want { + t.Errorf("%s([]rune(%q), %d) = %d, want %d", c.name, c.text, c.pos, got, c.want) + } + }) + } +} diff --git a/ui/state/values_state.go b/ui/state/values_state.go index 5df1606..344da0e 100644 --- a/ui/state/values_state.go +++ b/ui/state/values_state.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "gioui.org/layout" "gioui.org/widget" "gopkg.in/yaml.v3" @@ -308,12 +309,25 @@ type ValuesPageState struct { Columns [MaxCustomColumns]CustomColumnState ColumnCount int // 1-3 active columns - // Unified table: single list with search and filtering + // Unified table: single list. Search highlights and jumps between matches + // rather than filtering, so FilteredIndices holds every visible row (all + // entries minus collapsed sections), independent of the search query. OverrideList widget.List SearchEditor widget.Editor FilteredIndices []int FocusSearch bool + // Search match navigation. SearchMatches holds the entry indices (into + // Entries) whose key/value/comment/override text contains the current + // query, in entry order; empty when the query is empty. CurrentMatch is the + // index into SearchMatches of the row the user has jumped to (counter shows + // CurrentMatch+1), or -1 when there are no matches. SearchPrevButton / + // SearchNextButton drive the in-bar jump controls. + SearchMatches []int + CurrentMatch int + SearchPrevButton widget.Clickable + SearchNextButton widget.Clickable + // Cell navigation state. // FocusedRow indexes FilteredIndices (not Entries); FocusedCol indexes // active override columns. PendingFocusKey holds a restored entry key that @@ -431,6 +445,14 @@ func (s *ValuesPageState) FocusedEntryKey() string { return s.Entries[idx].Key } +// SearchFocused reports whether the user is currently working the search bar +func (s *ValuesPageState) SearchFocused(gtx layout.Context) bool { + return s.SearchEditor.Text() != "" && + (gtx.Focused(&s.SearchEditor) || + gtx.Focused(&s.SearchPrevButton) || + gtx.Focused(&s.SearchNextButton)) +} + // firstCustomValues returns the first column with a loaded custom file, or // nil. Comments are sourced from this column only — the chart-defaults file // is excluded by design, since the user is editing the values file and only diff --git a/ui/widget/label.go b/ui/widget/label.go index 30a81bc..6bf9a05 100644 --- a/ui/widget/label.go +++ b/ui/widget/label.go @@ -41,6 +41,8 @@ const glyphBatchSize = 256 // wash because it covers much more area. var SearchHighlightColor = color.NRGBA{R: 255, G: 215, B: 0, A: 200} //nolint:mnd // saturated amber highlight +var CurrentMatchHighlightColor = color.NRGBA{R: 255, G: 122, B: 0, A: 235} //nolint:mnd // saturated orange highlight + // LayoutEditor lays out a material.EditorStyle, rendering the hint text with // the larger glyph batch so it doesn't exhibit the vertical-stripe artefact. // The shaper must be the same *text.Shaper used by the theme (th.Shaper) since @@ -103,11 +105,17 @@ func LabelWidget(l material.LabelStyle) layout.Widget { } } -// LayoutHighlightedLabel renders lbl with a yellow rectangle painted behind -// the first case-insensitive occurrence of query inside lbl.Text. Forces +// LayoutHighlightedLabel renders lbl with the amber SearchHighlightColor wash +// behind the first match of query. See LayoutHighlightedLabelColor. +func LayoutHighlightedLabel(gtx layout.Context, lbl material.LabelStyle, query string) layout.Dimensions { + return LayoutHighlightedLabelColor(gtx, lbl, query, SearchHighlightColor) +} + +// LayoutHighlightedLabelColor renders lbl with a hl-colored rectangle painted +// behind the first case-insensitive occurrence of query inside lbl.Text. Forces // MaxLines to 1 so the highlight rect can't smear across wrapped lines. // ASCII-only — see measureMatchRange. -func LayoutHighlightedLabel(gtx layout.Context, lbl material.LabelStyle, query string) layout.Dimensions { +func LayoutHighlightedLabelColor(gtx layout.Context, lbl material.LabelStyle, query string, hl color.NRGBA) layout.Dimensions { lbl.MaxLines = 1 if query == "" { @@ -129,7 +137,7 @@ func LayoutHighlightedLabel(gtx layout.Context, lbl material.LabelStyle, query s if x1 > x0 { rect := clip.Rect{Min: image.Pt(x0, 0), Max: image.Pt(x1, dims.Size.Y)}.Push(gtx.Ops) - paint.ColorOp{Color: SearchHighlightColor}.Add(gtx.Ops) + paint.ColorOp{Color: hl}.Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops) rect.Pop() } diff --git a/ui/widget/override_table.go b/ui/widget/override_table.go index c3bac54..66bbbe4 100644 --- a/ui/widget/override_table.go +++ b/ui/widget/override_table.go @@ -60,23 +60,24 @@ const ( overrideKeyProportion = 0.5 overrideValueProportion = 0.5 - overridePaddingV unit.Dp = 4 - overridePaddingH unit.Dp = 8 - overrideIndentPerLevel unit.Dp = 12 - overrideScrollbarWidth unit.Dp = 10 // MinorWidth(6) + 2*MinorPadding(2) - overrideSeparatorH unit.Dp = 1 - overrideTreeGuideW unit.Dp = 1 - overrideDividerW unit.Dp = 2 - overrideSubDividerW unit.Dp = 1 - overrideNoHover = -1 - overrideMarkerW unit.Dp = 4 - overrideMarkerMinH unit.Dp = 2 - overrideIndentGuideW unit.Dp = 1 - overrideIndentDotLen unit.Dp = 2 - overrideIndentDotGap unit.Dp = 3 - overrideChevronSlotW unit.Dp = 14 // fixed slot reserved before every key label - overrideChevronSize unit.Dp = 6 // edge length of the filled chevron triangle - overrideBadgeGap unit.Dp = 4 // left margin between the key label and anchor/alias badge + overridePaddingV unit.Dp = 4 + overridePaddingH unit.Dp = 8 + overrideIndentPerLevel unit.Dp = 12 + overrideScrollbarWidth unit.Dp = 10 // MinorWidth(6) + 2*MinorPadding(2) + overrideSeparatorH unit.Dp = 1 + overrideTreeGuideW unit.Dp = 1 + overrideDividerW unit.Dp = 2 + overrideSubDividerW unit.Dp = 1 + overrideNoHover = -1 + overrideMarkerW unit.Dp = 4 + overrideMarkerMinH unit.Dp = 2 + overrideMatchMarkerCurH unit.Dp = 4 // active-match scrollbar tick — taller than the override/match ticks + overrideIndentGuideW unit.Dp = 1 + overrideIndentDotLen unit.Dp = 2 + overrideIndentDotGap unit.Dp = 3 + overrideChevronSlotW unit.Dp = 14 // fixed slot reserved before every key label + overrideChevronSize unit.Dp = 6 // edge length of the filled chevron triangle + overrideBadgeGap unit.Dp = 4 // left margin between the key label and anchor/alias badge // overrideCommentMaxLines clamps how many lines an orphan-comment row paints. // Foot blocks in real chart values can be 20+ lines (commented-out YAML // example configurations are common), and rendering all of them would push @@ -145,6 +146,14 @@ type OverrideTable struct { // Set by the page before Layout each frame. SearchQuery string + // CurrentMatchEntry is the entry index of the active search match (the hit + // the prev/next buttons last jumped to), or -1 when none. + CurrentMatchEntry int + + // SearchMatches holds the entry indices that match the active query, in + // ascending order, or empty when not searching. + SearchMatches []int + hovers []gesture.Hover cellClicks []gesture.Click collapseClicks []gesture.Click @@ -497,6 +506,7 @@ func (t *OverrideTable) layoutRow( indent := overrideIndentPerLevel * unit.Dp(max(0, entry.Depth-1)) section := entry.IsSection() + isCurrentMatch := entryIdx == t.CurrentMatchEntry hovered := t.hovers[index].Update(gtx.Source) if hovered { @@ -636,7 +646,12 @@ func (t *OverrideTable) layoutRow( return t.layoutChevronSlot(gtx, index, entry.Key, section) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return LayoutHighlightedLabel(gtx, lbl, t.SearchQuery) + hl := SearchHighlightColor + if isCurrentMatch { + hl = CurrentMatchHighlightColor + } + + return LayoutHighlightedLabelColor(gtx, lbl, t.SearchQuery, hl) }), ) }) @@ -711,6 +726,10 @@ func (t *OverrideTable) layoutRow( gitStatus = t.gitChangeStatus(entries[entryIdx].Key) } + if isCurrentMatch { + paintRowBg(gtx, dims.Size.Y, theme.Default.RowSelected) + } + // Paint hover / selected wash first so the per-axis tints below // (override, git, extra) can overpaint the right side and only the // key column carries hover feedback when the row is also tagged. @@ -1230,7 +1249,7 @@ func (t *OverrideTable) CurrentParent(entries []service.FlatValueEntry, filtered return "" } -// layoutScrollbarMarkers draws colored markers alongside the scrollbar for overridden entries. +// layoutScrollbarMarkers draws colored markers alongside the scrollbar func (t *OverrideTable) layoutScrollbarMarkers( gtx layout.Context, entries []service.FlatValueEntry, @@ -1248,23 +1267,50 @@ func (t *OverrideTable) layoutScrollbarMarkers( markerH := max(gtx.Dp(overrideMarkerMinH), 1) scrollX := totalW - gtx.Dp(overrideScrollbarWidth) + markerY := func(visIdx int) int { + return int(float64(visIdx) / float64(totalEntries) * float64(totalH)) + } + for visIdx, entryIdx := range filteredIndices { - if entryIdx >= len(entries) { + if entryIdx >= len(entries) || !t.hasAnyOverride(entryIdx) { continue } - if !t.hasAnyOverride(entryIdx) { - continue + paintScrollMarker(gtx, scrollX, markerY(visIdx), markerW, markerH, theme.Default.Override) + } + + if len(t.SearchMatches) == 0 { + return + } + + // Match markers span the full gutter so they read as a distinct band from + // the thin override ticks. SearchMatches and filteredIndices are both in + // ascending entry order, so a single forward pointer resolves membership. + gutterW := gtx.Dp(overrideScrollbarWidth) + currentH := max(gtx.Dp(overrideMatchMarkerCurH), markerH) + mi := 0 + + for visIdx, entryIdx := range filteredIndices { + for mi < len(t.SearchMatches) && t.SearchMatches[mi] < entryIdx { + mi++ } - y := int(float64(visIdx) / float64(totalEntries) * float64(totalH)) + if mi >= len(t.SearchMatches) || t.SearchMatches[mi] != entryIdx { + continue + } - rect := clip.Rect{ - Min: image.Pt(scrollX, y), - Max: image.Pt(scrollX+markerW, y+markerH), - }.Push(gtx.Ops) - paint.ColorOp{Color: theme.Default.Override}.Add(gtx.Ops) - paint.PaintOp{}.Add(gtx.Ops) - rect.Pop() + if entryIdx == t.CurrentMatchEntry { + paintScrollMarker(gtx, scrollX, markerY(visIdx), gutterW, currentH, CurrentMatchHighlightColor) + } else { + paintScrollMarker(gtx, scrollX, markerY(visIdx), gutterW, markerH, SearchHighlightColor) + } } } + +// paintScrollMarker fills a w×h rectangle at (x, y) with c — one scrollbar tick. +func paintScrollMarker(gtx layout.Context, x, y, w, h int, c color.NRGBA) { + rect := clip.Rect{Min: image.Pt(x, y), Max: image.Pt(x+w, y+h)}.Push(gtx.Ops) + paint.ColorOp{Color: c}.Add(gtx.Ops) + paint.PaintOp{}.Add(gtx.Ops) + rect.Pop() +} diff --git a/ui/widget/search_bar.go b/ui/widget/search_bar.go index 8765999..79ce8c6 100644 --- a/ui/widget/search_bar.go +++ b/ui/widget/search_bar.go @@ -15,7 +15,8 @@ import ( "github.com/qdeck-app/qdeck/ui/theme" ) -// SearchBar wraps an Editor and provides filtered index computation. +// SearchBar wraps an Editor and computes the indices of matching entries so the +// caller can highlight and jump between them (it does not filter the table). type SearchBar struct { Editor *widget.Editor @@ -77,11 +78,13 @@ func (s *SearchBar) rebuildCacheIfNeeded(entries []service.FlatValueEntry) { }) } -// FilterEntriesWithMultiOverrides returns indices matching key, value, comment, -// or override editor text across multiple columns. +// MatchingEntries returns the indices of entries whose key, value, comment, or +// override editor text (across columns) contains the current query. Unlike a +// filter, an empty query yields no matches — the table shows every row and the +// caller navigates between these indices instead of hiding non-matches. // // Reuses the provided out slice to avoid per-frame allocations. -func (s *SearchBar) FilterEntriesWithMultiOverrides( +func (s *SearchBar) MatchingEntries( entries []service.FlatValueEntry, columnEditors [][]widget.Editor, out []int, @@ -89,11 +92,11 @@ func (s *SearchBar) FilterEntriesWithMultiOverrides( query := strings.ToLower(s.Editor.Text()) out = out[:0] - matchesQuery := func(i int) bool { - if query == "" { - return true - } + if query == "" { + return out + } + matchesQuery := func(i int) bool { if strings.Contains(s.lowerKeys[i], query) || strings.Contains(s.lowerValues[i], query) || strings.Contains(s.lowerComments[i], query) { @@ -109,11 +112,17 @@ func (s *SearchBar) FilterEntriesWithMultiOverrides( return false } - if query != "" { - s.rebuildCacheIfNeeded(entries) - } + s.rebuildCacheIfNeeded(entries) for i := range entries { + // Orphan-comment rows have an empty Key, so the caller can't scroll to + // or highlight them — counting them would yield phantom matches the + // prev/next buttons skip over. Their prose is still reachable via the + // key/value of neighbouring rows. + if entries[i].IsComment() { + continue + } + if !matchesQuery(i) { continue } @@ -131,7 +140,10 @@ const ( ) // Layout renders the search text field with a border spanning the full width. -func (s *SearchBar) Layout(gtx layout.Context, th *material.Theme, hint string) layout.Dimensions { +// trailing, when non-nil, is laid out as a rigid to the right of the editor +// (e.g. the match counter and prev/next navigation buttons); it is vertically +// centered against the editor so the controls share the field's baseline. +func (s *SearchBar) Layout(gtx layout.Context, th *material.Theme, hint string, trailing layout.Widget) layout.Dimensions { borderW := gtx.Dp(searchBorderWidth) width := gtx.Constraints.Max.X @@ -148,7 +160,7 @@ func (s *SearchBar) Layout(gtx layout.Context, th *material.Theme, hint string) return layout.Dimensions{Size: sz} }), - // Editor content. + // Editor content plus optional trailing navigation controls. layout.Stacked(func(gtx layout.Context) layout.Dimensions { gtx.Constraints.Min.X = width @@ -156,9 +168,20 @@ func (s *SearchBar) Layout(gtx layout.Context, th *material.Theme, hint string) Top: searchPaddingV, Bottom: searchPaddingV, Left: searchPaddingH, Right: searchPaddingH, }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - editor := material.Editor(th, s.Editor, hint) + editorField := func(gtx layout.Context) layout.Dimensions { + editor := material.Editor(th, s.Editor, hint) + + return LayoutEditor(gtx, th.Shaper, editor) + } + + if trailing == nil { + return editorField(gtx) + } - return LayoutEditor(gtx, th.Shaper, editor) + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, editorField), + layout.Rigid(trailing), + ) }) }), )