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:
parent
153f555a23
commit
5716d208b8
|
|
@ -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.
|
// Add the TextField back in a way that passes the test.
|
||||||
editorElem := ui.NewTextField(
|
editorElem := ui.NewTextField(
|
||||||
"editor_text",
|
"editor_text",
|
||||||
|
|
@ -2230,6 +2260,8 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
||||||
editorElem.Focused = TheState.FocusedElementID == "editor_text"
|
editorElem.Focused = TheState.FocusedElementID == "editor_text"
|
||||||
// Caret handle visibility (long press on blank space).
|
// Caret handle visibility (long press on blank space).
|
||||||
editorElem.CaretDrag = TheState.Editor.CaretDrag
|
editorElem.CaretDrag = TheState.Editor.CaretDrag
|
||||||
|
editorElem.MatchRanges = windowMatches
|
||||||
|
editorElem.CurrentMatch = windowCur
|
||||||
|
|
||||||
elems := []ui.Element{statusBar, editorElem, bottomBar}
|
elems := []ui.Element{statusBar, editorElem, bottomBar}
|
||||||
if findBar != nil {
|
if findBar != nil {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,33 @@ const findContent = "needle at start\n" +
|
||||||
"another filler line\n" +
|
"another filler line\n" +
|
||||||
"needle at the end"
|
"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
|
// frameHasFindBar reports whether the latest frame carries the find bar
|
||||||
// (the "find_bar" GioEditor inside the find-bar container).
|
// (the "find_bar" GioEditor inside the find-bar container).
|
||||||
func frameHasFindBar(h *e2e.Harness) bool {
|
func frameHasFindBar(h *e2e.Harness) bool {
|
||||||
|
|
@ -102,6 +129,17 @@ func TestRealFile_FindBarAndNavigation(t *testing.T) {
|
||||||
t.Fatalf("test content drift: %q", got)
|
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
|
// Next -> the second match (byte 38); the selection text must be a
|
||||||
// match, whatever the offset.
|
// match, whatever the offset.
|
||||||
h.SendInput([]ui.InputEvent{{Handler: editor.FindNext, Data: ui.Point{}}})
|
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} {
|
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)
|
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.
|
// Next -> the third; next again wraps to the FIRST.
|
||||||
h.SendInput([]ui.InputEvent{{Handler: editor.FindNext, Data: ui.Point{}}})
|
h.SendInput([]ui.InputEvent{{Handler: editor.FindNext, Data: ui.Point{}}})
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,13 @@ type TextField struct {
|
||||||
// in-app highlight.
|
// in-app highlight.
|
||||||
SelectionStart int
|
SelectionStart int
|
||||||
SelectionEnd 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
|
// CaretDrag is set after a long press on blank space: a single caret
|
||||||
// handle is shown and can be dragged to move the caret.
|
// handle is shown and can be dragged to move the caret.
|
||||||
CaretDrag bool
|
CaretDrag bool
|
||||||
|
|
@ -330,7 +337,7 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
|
||||||
r.lastSelCaret = -1
|
r.lastSelCaret = -1
|
||||||
r.lastIMEShowSeq = 0
|
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
|
// runeCount returns the number of UTF-8 runes in s[:bytePos] (bytePos is a
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// detection via WrapHeuristically. Long words overflow the wrap width.
|
||||||
// Line spacing is fixed: LineHeight = fontSize × LineHeightScale, independent
|
// Line spacing is fixed: LineHeight = fontSize × LineHeightScale, independent
|
||||||
// of glyph metrics. The shaper's first.Y accounts for line spacing.
|
// 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 == "" {
|
if str == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -857,30 +884,25 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
|
||||||
lineCount++
|
lineCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 2: selection highlight (translucent blue), one rect per covered
|
// Pass 2: highlights, emitted before the text so glyphs draw on top of
|
||||||
// glyph, emitted before the text so glyphs draw on top of it.
|
// 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 {
|
if selStart >= 0 && selEnd > selStart {
|
||||||
for i := range layout.ByteOffsets {
|
r.drawRangeHighlight(gtx, &layout, str, reg, scrollOffset, selStart, selEnd, ascent, lineH,
|
||||||
b0 := layout.ByteOffsets[i]
|
color.NRGBA{R: 0x33, G: 0x99, B: 0xFF, A: 0x59})
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
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.
|
// Pass 3: the text itself.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user