Highlight search matches in the text view

- ui: TextField gains MatchRanges (window-relative byte ranges) +
  CurrentMatch; drawWrappedText draws a per-glyph highlight pass (same
  tiling as the selection, so wrapped lines are covered): all matches in
  translucent yellow, the selection in blue as before, and the current
  match in stronger orange on top so it stands out.
- editor: EditorLayout windows the absolute FindState matches into the
  visible content (binary search on the sorted ranges, clamp to the
  window) and stamps them on the editor TextField.
- e2e: poll the captured frames until the highlight data lands; assert
  all matches are windowed, each highlights a "needle", and
  CurrentMatch follows FindNext.
This commit is contained in:
Greg Pomerantz 2026-08-20 06:29:09 -04:00
parent 153f555a23
commit 5716d208b8
4 changed files with 128 additions and 25 deletions

View File

@ -2181,6 +2181,36 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
}
}
// Window-relative search matches for the in-text highlight (byte ranges
// into visibleContent). Matches are sorted, so binary-search the first
// that can intersect the window and walk forward. The window is
// [start, start+len(visibleContent)).
var windowMatches [][2]int
windowCur := -1
if f := TheState.Editor.Find; f.Visible && len(f.Matches) > 0 && !TheState.Editor.TooLarge {
winEnd := start + len(visibleContent)
n := len(f.Matches)
i := sort.Search(n, func(i int) bool { return f.Matches[i][0] >= start })
if i > 0 {
i-- // a match starting before the window may extend into it
}
for ; i < n && f.Matches[i][0] < winEnd; i++ {
ws, we := f.Matches[i][0]-start, f.Matches[i][1]-start
if ws < 0 {
ws = 0
}
if we > len(visibleContent) {
we = len(visibleContent)
}
if ws < we {
if i == f.Cur {
windowCur = len(windowMatches)
}
windowMatches = append(windowMatches, [2]int{ws, we})
}
}
}
// Add the TextField back in a way that passes the test.
editorElem := ui.NewTextField(
"editor_text",
@ -2230,6 +2260,8 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
editorElem.Focused = TheState.FocusedElementID == "editor_text"
// Caret handle visibility (long press on blank space).
editorElem.CaretDrag = TheState.Editor.CaretDrag
editorElem.MatchRanges = windowMatches
editorElem.CurrentMatch = windowCur
elems := []ui.Element{statusBar, editorElem, bottomBar}
if findBar != nil {

View File

@ -18,6 +18,33 @@ const findContent = "needle at start\n" +
"another filler line\n" +
"needle at the end"
// editorField returns the editor's TextField from the latest frame (the
// frame element whose value is the file's visible content), if present.
func editorField(h *e2e.Harness) (ui.TextField, bool) {
for _, el := range e2e.GetLastFrame(h) {
if tf, ok := el.(ui.TextField); ok && strings.Contains(tf.Value, "needle") {
return tf, true
}
}
return ui.TextField{}, false
}
// waitForEditorField polls the captured frames (frame capture lags the
// logic-owner state) until the editor TextField satisfies want, and returns
// it.
func waitForEditorField(t *testing.T, h *e2e.Harness, want func(ui.TextField) bool) ui.TextField {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if tf, ok := editorField(h); ok && want(tf) {
return tf
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for an editor frame satisfying the predicate")
return ui.TextField{}
}
// frameHasFindBar reports whether the latest frame carries the find bar
// (the "find_bar" GioEditor inside the find-bar container).
func frameHasFindBar(h *e2e.Harness) bool {
@ -102,6 +129,17 @@ func TestRealFile_FindBarAndNavigation(t *testing.T) {
t.Fatalf("test content drift: %q", got)
}
// The in-text highlight data rides the frame: all three matches are
// windowed into the visible content, the first flagged as current.
tf := waitForEditorField(t, h, func(tf ui.TextField) bool {
return len(tf.MatchRanges) == 3 && tf.CurrentMatch == 0
})
for i, m := range tf.MatchRanges {
if !strings.EqualFold(tf.Value[m[0]:m[1]], "needle") {
t.Fatalf("match %d highlights %q, want a needle", i, tf.Value[m[0]:m[1]])
}
}
// Next -> the second match (byte 38); the selection text must be a
// match, whatever the offset.
h.SendInput([]ui.InputEvent{{Handler: editor.FindNext, Data: ui.Point{}}})
@ -111,6 +149,10 @@ func TestRealFile_FindBarAndNavigation(t *testing.T) {
if want := strings.Index(findContent[6:], "needle") + 6; sel.([2]int) != [2]int{want, want + 6} {
t.Fatalf("selection %v, want [%d,%d)", sel, want, want+6)
}
// The current-match highlight follows the navigation.
waitForEditorField(t, h, func(tf ui.TextField) bool {
return len(tf.MatchRanges) == 3 && tf.CurrentMatch == 1
})
// Next -> the third; next again wraps to the FIRST.
h.SendInput([]ui.InputEvent{{Handler: editor.FindNext, Data: ui.Point{}}})

View File

@ -192,6 +192,13 @@ type TextField struct {
// in-app highlight.
SelectionStart int
SelectionEnd int
// MatchRanges are byte ranges [start,end) into Value (the visible
// window) of in-file search matches; the renderer draws them as yellow
// highlights. CurrentMatch is the index into MatchRanges of the match the
// user has navigated to (it is also the active selection); it gets the
// stronger highlight so it stands out among the others. -1 = none.
MatchRanges [][2]int
CurrentMatch int
// CaretDrag is set after a long press on blank space: a single caret
// handle is shown and can be dragged to move the caret.
CaretDrag bool
@ -330,7 +337,7 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
r.lastSelCaret = -1
r.lastIMEShowSeq = 0
}
r.drawWrappedText(gtx, tf.Value, tf.region, tf.WordWrap, tf.WrapWidth, tf.ScrollOffset, tf.CursorPosition, tf.SelectionStart, tf.SelectionEnd, tf.CaretDrag)
r.drawWrappedText(gtx, tf.Value, tf.region, tf.WordWrap, tf.WrapWidth, tf.ScrollOffset, tf.CursorPosition, tf.SelectionStart, tf.SelectionEnd, tf.CaretDrag, tf.MatchRanges, tf.CurrentMatch)
}
// runeCount returns the number of UTF-8 runes in s[:bytePos] (bytePos is a

View File

@ -750,7 +750,34 @@ func (r *Renderer) drawLine(gtx layout.Context, line []text.Glyph, x, y Dp, col
// detection via WrapHeuristically. Long words overflow the wrap width.
// Line spacing is fixed: LineHeight = fontSize × LineHeightScale, independent
// of glyph metrics. The shaper's first.Y accounts for line spacing.
func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, wordWrap bool, wrapWidth Dp, scrollOffset Dp, cursorPos, selStart, selEnd int, caretDrag bool) {
// drawRangeHighlight paints one translucent rect per glyph covered by the
// window-relative byte range [start,end), the same per-glyph tiling the
// selection highlight uses, so wrapped lines are covered too.
func (r *Renderer) drawRangeHighlight(gtx layout.Context, layout *GlyphLayout, str string, reg Region, scrollOffset Dp, start, end int, ascent, lineH Dp, c color.NRGBA) {
for i := range layout.ByteOffsets {
b0 := layout.ByteOffsets[i]
b1 := len(str)
if i+1 < len(layout.ByteOffsets) {
b1 = layout.ByteOffsets[i+1]
}
if b0 >= end || b1 <= start {
continue
}
hx := reg.X + layout.X[i]
hy := reg.Y - scrollOffset + layout.Y[i] - ascent
hw := layout.Advance[i]
hh := lineH
rect := clip.Rect{
Min: image.Point{X: int(r.toPx(hx)), Y: int(r.toPx(hy))},
Max: image.Point{X: int(r.toPx(hx + hw)), Y: int(r.toPx(hy + hh))},
}.Op().Push(gtx.Ops)
paint.ColorOp{Color: c}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
rect.Pop()
}
}
func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, wordWrap bool, wrapWidth Dp, scrollOffset Dp, cursorPos, selStart, selEnd int, caretDrag bool, matchRanges [][2]int, currentMatch int) {
if str == "" {
return
}
@ -857,31 +884,26 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
lineCount++
}
// Pass 2: selection highlight (translucent blue), one rect per covered
// glyph, emitted before the text so glyphs draw on top of it.
if selStart >= 0 && selEnd > selStart {
for i := range layout.ByteOffsets {
b0 := layout.ByteOffsets[i]
b1 := len(str)
if i+1 < len(layout.ByteOffsets) {
b1 = layout.ByteOffsets[i+1]
}
if b0 >= selEnd || b1 <= selStart {
continue
}
hx := reg.X + layout.X[i]
hy := reg.Y - scrollOffset + layout.Y[i] - ascent
hw := layout.Advance[i]
hh := lineH
rect := clip.Rect{
Min: image.Point{X: int(r.toPx(hx)), Y: int(r.toPx(hy))},
Max: image.Point{X: int(r.toPx(hx + hw)), Y: int(r.toPx(hy + hh))},
}.Op().Push(gtx.Ops)
paint.ColorOp{Color: color.NRGBA{R: 0x33, G: 0x99, B: 0xFF, A: 0x59}}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
rect.Pop()
// Pass 2: highlights, emitted before the text so glyphs draw on top of
// them. In-file search matches first (translucent yellow), then the
// selection (translucent blue), then the CURRENT search match again in a
// stronger orange so it stands out even though the selection also covers
// it.
for i, m := range matchRanges {
if i != currentMatch {
r.drawRangeHighlight(gtx, &layout, str, reg, scrollOffset, m[0], m[1], ascent, lineH,
color.NRGBA{R: 0xFF, G: 0xE2, B: 0x4D, A: 0x66})
}
}
if selStart >= 0 && selEnd > selStart {
r.drawRangeHighlight(gtx, &layout, str, reg, scrollOffset, selStart, selEnd, ascent, lineH,
color.NRGBA{R: 0x33, G: 0x99, B: 0xFF, A: 0x59})
}
if currentMatch >= 0 && currentMatch < len(matchRanges) {
m := matchRanges[currentMatch]
r.drawRangeHighlight(gtx, &layout, str, reg, scrollOffset, m[0], m[1], ascent, lineH,
color.NRGBA{R: 0xFF, G: 0x98, B: 0x00, A: 0x80})
}
// Pass 3: the text itself.
m := op.Record(gtx.Ops)