From c3969bb395acd5d3338baf12af6eb2277a2a5997 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 7 Nov 2026 15:23:49 -0800 Subject: [PATCH] fix: preserve trailing zero-width caret geometry --- internal/text/caret.go | 5 +++-- internal/text/caret_test.go | 30 ++++++++++++++++++++++++++++++ internal/text/edit.go | 15 +++++++++++++-- internal/text/layout.go | 8 ++++++-- 4 files changed, 52 insertions(+), 6 deletions(-) diff --git a/internal/text/caret.go b/internal/text/caret.go index a16ae4c85..9ded914f7 100644 --- a/internal/text/caret.go +++ b/internal/text/caret.go @@ -58,9 +58,10 @@ func (l *Layout) visualStops(li int) []caretStop { if _, ok := slices.BinarySearch(l.boundaries, at); !ok { continue } - x := x0 + (x1-x0)*float32(k)/float32(g.Runes) + f := line.clusterFraction(g.Cluster, g.Runes, k) + x := x0 + (x1-x0)*f if g.RTL { - x = x1 - (x1-x0)*float32(k)/float32(g.Runes) + x = x1 - (x1-x0)*f } affinity, arrival := Downstream, 0 if k == 0 { diff --git a/internal/text/caret_test.go b/internal/text/caret_test.go index 73118a32d..ef151a519 100644 --- a/internal/text/caret_test.go +++ b/internal/text/caret_test.go @@ -20,6 +20,36 @@ func mixedLayout() *Layout { return l } +func TestTrailingZeroWidthClusterGeometry(t *testing.T) { + runes := []rune("x\u200b") + shaped := shapedLine{end: len(runes), runs: []shapedRun{{ + end: len(runes), glyphs: []Glyph{{Advance: 10}}, + }}} + var y float32 + var buf []int + row := line(Params{}, runes, shaped, 0, false, lineMetrics{lineHeight: 20}, &y, nil, &buf, nil) + l := &Layout{Runes: runes, Lines: []Line{row}} + for _, index := range []int{1, 2} { + if x, _, _ := l.Caret(index); x != 10 { + t.Errorf("Caret(%d) = %v, want 10", index, x) + } + for _, affinity := range []Affinity{Upstream, Downstream} { + if x, _, _ := l.CaretAt(CaretPosition{index, affinity}); x != 10 { + t.Errorf("CaretAt(%d, %v) = %v, want 10", index, affinity, x) + } + } + } + if p := l.PositionAt(10, 10); p.Index != 1 { + t.Errorf("hit at visible end = %+v, want index 1", p) + } + if rs := l.Selection(0, 1); len(rs) != 1 || rs[0].X != 0 || rs[0].W != 10 { + t.Errorf("visible selection = %+v, want full glyph advance", rs) + } + if rs := l.Selection(1, 2); len(rs) != 0 { + t.Errorf("zero-width selection = %+v, want no area", rs) + } +} + func TestVisualCaretAffinityAndNavigation(t *testing.T) { l := mixedLayout() a, _, _ := l.CaretAt(CaretPosition{4, Upstream}) diff --git a/internal/text/edit.go b/internal/text/edit.go index 3a94b74e6..376279fa1 100644 --- a/internal/text/edit.go +++ b/internal/text/edit.go @@ -11,6 +11,16 @@ import ( // Rect is a rectangle in DIPs relative to the layout. type Rect struct{ X, Y, W, H float32 } +// Trailing zero-width breaks may share a visible glyph's cluster, but +// must not take part of its advance as another ligature component. +func (line *Line) clusterFraction(start, runes, k int) float32 { + if end := line.zeroWidthStart; end >= 0 && start < end && start+runes > end { + runes = end - start + k = min(k, runes) + } + return float32(k) / float32(runes) +} + // lineCarets returns the x of every caret position of a line, from Start // to End: inside a cluster of several runes (a ligature) the positions // split the cluster evenly. It caches them in the line, so a layout's @@ -39,7 +49,7 @@ func (line *Line) lineCarets() []float32 { } } pos := func(c cluster, k int) float32 { - f := float32(k) / float32(c.runes) + f := line.clusterFraction(c.start, c.runes, k) if c.rtl { return c.x1 - (c.x1-c.x0)*f } @@ -182,7 +192,8 @@ func (l *Layout) AppendSelection(dst []Rect, start, end int, on bool) []Rect { continue } width := x1 - x0 - left, right := float32(a-first.Cluster)/float32(first.Runes), float32(z-first.Cluster)/float32(first.Runes) + left := line.clusterFraction(first.Cluster, first.Runes, a-first.Cluster) + right := line.clusterFraction(first.Cluster, first.Runes, z-first.Cluster) if first.RTL { left, right = 1-right, 1-left } diff --git a/internal/text/layout.go b/internal/text/layout.go index f6e85ea4e..8535e4686 100644 --- a/internal/text/layout.go +++ b/internal/text/layout.go @@ -371,7 +371,8 @@ type Line struct { upstream []float32 // padding is the room the padding of spans' pieces takes in the line // (Span.PaddingX). - padding float32 + padding float32 + zeroWidthStart int } // Glyph is a positioned glyph. @@ -1440,7 +1441,10 @@ func glyphCount(sl shapedLine) int { // offset of the layout, with its top at *y, and moves *y past it. Its // glyphs go to room, which has room for those of sl; buf is a buffer. func line(p Params, text []rune, sl shapedLine, offset int, rtl bool, m lineMetrics, y *float32, room []Glyph, buf *[]int, spans []Span) Line { - line := Line{Start: offset + sl.start, End: offset + sl.end, RTL: rtl, Ascent: m.ascent, Descent: m.descent} + line := Line{Start: offset + sl.start, End: offset + sl.end, RTL: rtl, Ascent: m.ascent, Descent: m.descent, zeroWidthStart: -1} + for end := sl.end; end > sl.start && text[end-1] == '\u200b'; end-- { + line.zeroWidthStart = offset + end - 1 + } if cap(room) > 0 { line.Glyphs = room }