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.
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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{}}})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user