diff --git a/internal/editor/state.go b/internal/editor/state.go index 9a177be..4449dbc 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -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 { diff --git a/internal/test/e2e/find_test.go b/internal/test/e2e/find_test.go index fcacd1b..29981d4 100644 --- a/internal/test/e2e/find_test.go +++ b/internal/test/e2e/find_test.go @@ -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{}}}) diff --git a/internal/ui/element.go b/internal/ui/element.go index c175518..ad8e30b 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -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 diff --git a/internal/ui/render.go b/internal/ui/render.go index f7609b7..7d677ce 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -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)