detached 701c8cc

refactor(gui): deepen editor geometry and frame ownership

commit
701c8cccf78f179f6c61ca01c42585f1a171fff2
author
brice · · 2026-09-30 03:20 UTC
parent
5203207
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +995 -185
M controls.go +2 -10
@@ -99,15 +99,7 @@ func (n Node) editControl(typ, label string, value *string, options []EditOption
9999 switch input.Kind {
100100 case "edit-pointer", "edit-word":
101101 editor.PreferredXValid = false
102- rendered := editor.Value
103- if child.element.password {
104- rendered = editor.Mask()
105- }
106- place := nearestTextBoundary(rendered, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, input.X, input.Y)
107- if child.element.password {
108- index := len(edit.Boundaries(rendered[:place])) - 1
109- place = edit.Boundaries(editor.Value)[index]
110- }
102+ place := newEditorGeometry(editor, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, child.element.password).nearest(input.X, input.Y)
111103 if input.Kind == "edit-word" {
112104 start, end := editor.WordAt(place)
113105 editor.Select(start, end)
@@ -119,7 +111,7 @@ func (n Node) editControl(typ, label string, value *string, options []EditOption
119111 ev.changed = editor.Insert(input.Text) || ev.changed
120112 case "edit-key":
121113 if (input.Text == "up" || input.Text == "down") && typ == "textarea" {
122- moveVisualLine(editor, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, input.Text == "down", input.Shift)
114+ moveVisualLine(editor, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, child.element.password, input.Text == "down", input.Shift)
123115 } else {
124116 editor.PreferredXValid = false
125117 if input.Ctrl && input.Text == "v" && child.frame.owner != nil {
@@ -99,15 +99,7 @@ func (n Node) editControl(typ, label string, value *string, options []EditOption
99 switch input.Kind {99 switch input.Kind {
100 case "edit-pointer", "edit-word":100 case "edit-pointer", "edit-word":
101 editor.PreferredXValid = false101 editor.PreferredXValid = false
102- rendered := editor.Value102+ place := newEditorGeometry(editor, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, child.element.password).nearest(input.X, input.Y)
103- if child.element.password {
104- rendered = editor.Mask()
105- }
106- place := nearestTextBoundary(rendered, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, input.X, input.Y)
107- if child.element.password {
108- index := len(edit.Boundaries(rendered[:place])) - 1
109- place = edit.Boundaries(editor.Value)[index]
110- }
111 if input.Kind == "edit-word" {103 if input.Kind == "edit-word" {
112 start, end := editor.WordAt(place)104 start, end := editor.WordAt(place)
113 editor.Select(start, end)105 editor.Select(start, end)
@@ -119,7 +111,7 @@ func (n Node) editControl(typ, label string, value *string, options []EditOption
119 ev.changed = editor.Insert(input.Text) || ev.changed111 ev.changed = editor.Insert(input.Text) || ev.changed
120 case "edit-key":112 case "edit-key":
121 if (input.Text == "up" || input.Text == "down") && typ == "textarea" {113 if (input.Text == "up" || input.Text == "down") && typ == "textarea" {
122- moveVisualLine(editor, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, input.Text == "down", input.Shift)114+ moveVisualLine(editor, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, child.element.password, input.Text == "down", input.Shift)
123 } else {115 } else {
124 editor.PreferredXValid = false116 editor.PreferredXValid = false
125 if input.Ctrl && input.Text == "v" && child.frame.owner != nil {117 if input.Ctrl && input.Text == "v" && child.frame.owner != nil {
M docs/controls.md +1 -1
@@ -8,7 +8,7 @@ Checkbox and radio paint a one-em indicator, centered vertically, in their left
88
99 The pointer cursor follows the CSS `cursor` of the hovered element (`pointer`, `text`, `not-allowed`; `auto` and `default` use the default arrow). While a primary button is held, the pressed element keeps its cursor. The UA sheet sets `pointer` on buttons, checkboxes, radios and sliders, `text` on inputs and textareas, and `not-allowed` on disabled controls. NeferGUI sets the shape through `wp_cursor_shape_manager_v1` when the compositor offers it; otherwise the compositor's cursor stays unchanged. `Placeholder` paints dimmed text (half opacity) in an empty `Input` or `Textarea` until a value or preedit exists; it never enters the bound value. The `label` argument of `Input` and `Textarea` is not painted; place a visible label with `Text`.
1010
11-`Checkbox` toggles `*bool`; `Radio(label, option, *string, ...)` selects an option in a string-valued group; `Slider(label, *float64, min, max, step, ...)` changes its pointer-backed value on arrow, Home/End and PageUp/Down keys. These controls never retain the model pointer. `Disabled(true)` blocks interaction. `Input` and `Textarea` bind `*string`; `Password(true)` masks the displayed string per Unicode grapheme and blocks copy/cut, but not paste. The editor stores cursor and anchor in UTF-8 byte offsets at extended-grapheme boundaries. Left/Right and selections are **logical**, not visual. Primary pointer press places the caret at the nearest shaped grapheme boundary, Shift+press extends from the existing anchor, and captured dragging extends even outside the control. A second press on the same control within 500 ms and 4 logical pixels selects exactly the word-iterator segment under that boundary; at a boundary the segment to the right wins, including whitespace or punctuation segments. An unrelated press resets this sequence. Pointer hit testing requires a measured text layout. Textarea Up/Down traverse wrapped visual lines at a retained preferred x; horizontal movement and edits reset it. The per-identity editor viewport scrolls vertically in Textarea and horizontally in unwrapped Input to keep the caret visible, clamping at the measured extent. Focused editors emit visual cluster highlight rectangles and a one-pixel caret inside the editor clip. At LTR/RTL run boundaries the preceding run supplies the caret edge. No undo/redo is implemented.
11+`Checkbox` toggles `*bool`; `Radio(label, option, *string, ...)` selects an option in a string-valued group; `Slider(label, *float64, min, max, step, ...)` changes its pointer-backed value on arrow, Home/End and PageUp/Down keys. These controls never retain the model pointer. `Disabled(true)` blocks interaction. `Input` and `Textarea` bind `*string`; `Password(true)` masks the displayed string per Unicode grapheme and blocks copy/cut, but not paste. The editor stores cursor and anchor in UTF-8 byte offsets at extended-grapheme boundaries. Left/Right and selections are **logical**, not visual. Primary pointer press places the caret at the nearest shaped grapheme boundary, Shift+press extends from the existing anchor, and captured dragging extends even outside the control. A second press on the same control within 500 ms and 4 logical pixels selects exactly the word-iterator segment under that boundary; at a boundary the segment to the right wins, including whitespace or punctuation segments. An unrelated press resets this sequence. Pointer hit testing requires a measured text layout. Textarea Up/Down traverse wrapped visual lines at a retained preferred x; horizontal movement and edits reset it. The per-identity editor viewport scrolls vertically in Textarea and horizontally in unwrapped Input to keep the caret visible, clamping at the measured extent. Focused editors emit visual cluster highlight rectangles and a one-pixel caret inside the editor clip. At LTR/RTL run boundaries and soft wraps, caret painting, scrolling and preedit positioning use the preceding character's trailing edge. Up/Down navigation treats a shared soft-wrap offset as belonging to the following line. Password geometry maps offsets by grapheme, like the displayed mask. No undo/redo is implemented.
1212
1313 `internal/edit` supplies the headless clipboard (`ReadText(limit)`, `WriteText`) and IME (`Enable`, `Disable`, `Surrounding`, `CursorRect`, `ContentType`) ports. A read is limited to 1 MiB and rejected unless UTF-8. IME batches represent preedit, commit and delete-surrounding relative to the UTF-8 cursor, processed at done. The preedit is shaped inline at the model cursor with an underline and its own cursor commands, without changing the model or its normal text run until commit. Password preedit is masked per grapheme before shaping or entering the display list. Ordinary keyboard text is inserted while an IME port is active, except while a preedit is shown; an input method that grabs the keyboard receives its keys instead of the editor. The ports do not negotiate MIME types, own FDs, or provide a Wayland adapter.
1414
@@ -8,7 +8,7 @@ Checkbox and radio paint a one-em indicator, centered vertically, in their left
8 8
9 The pointer cursor follows the CSS `cursor` of the hovered element (`pointer`, `text`, `not-allowed`; `auto` and `default` use the default arrow). While a primary button is held, the pressed element keeps its cursor. The UA sheet sets `pointer` on buttons, checkboxes, radios and sliders, `text` on inputs and textareas, and `not-allowed` on disabled controls. NeferGUI sets the shape through `wp_cursor_shape_manager_v1` when the compositor offers it; otherwise the compositor's cursor stays unchanged. `Placeholder` paints dimmed text (half opacity) in an empty `Input` or `Textarea` until a value or preedit exists; it never enters the bound value. The `label` argument of `Input` and `Textarea` is not painted; place a visible label with `Text`.9 The pointer cursor follows the CSS `cursor` of the hovered element (`pointer`, `text`, `not-allowed`; `auto` and `default` use the default arrow). While a primary button is held, the pressed element keeps its cursor. The UA sheet sets `pointer` on buttons, checkboxes, radios and sliders, `text` on inputs and textareas, and `not-allowed` on disabled controls. NeferGUI sets the shape through `wp_cursor_shape_manager_v1` when the compositor offers it; otherwise the compositor's cursor stays unchanged. `Placeholder` paints dimmed text (half opacity) in an empty `Input` or `Textarea` until a value or preedit exists; it never enters the bound value. The `label` argument of `Input` and `Textarea` is not painted; place a visible label with `Text`.
10 10
11-`Checkbox` toggles `*bool`; `Radio(label, option, *string, ...)` selects an option in a string-valued group; `Slider(label, *float64, min, max, step, ...)` changes its pointer-backed value on arrow, Home/End and PageUp/Down keys. These controls never retain the model pointer. `Disabled(true)` blocks interaction. `Input` and `Textarea` bind `*string`; `Password(true)` masks the displayed string per Unicode grapheme and blocks copy/cut, but not paste. The editor stores cursor and anchor in UTF-8 byte offsets at extended-grapheme boundaries. Left/Right and selections are **logical**, not visual. Primary pointer press places the caret at the nearest shaped grapheme boundary, Shift+press extends from the existing anchor, and captured dragging extends even outside the control. A second press on the same control within 500 ms and 4 logical pixels selects exactly the word-iterator segment under that boundary; at a boundary the segment to the right wins, including whitespace or punctuation segments. An unrelated press resets this sequence. Pointer hit testing requires a measured text layout. Textarea Up/Down traverse wrapped visual lines at a retained preferred x; horizontal movement and edits reset it. The per-identity editor viewport scrolls vertically in Textarea and horizontally in unwrapped Input to keep the caret visible, clamping at the measured extent. Focused editors emit visual cluster highlight rectangles and a one-pixel caret inside the editor clip. At LTR/RTL run boundaries the preceding run supplies the caret edge. No undo/redo is implemented.11+`Checkbox` toggles `*bool`; `Radio(label, option, *string, ...)` selects an option in a string-valued group; `Slider(label, *float64, min, max, step, ...)` changes its pointer-backed value on arrow, Home/End and PageUp/Down keys. These controls never retain the model pointer. `Disabled(true)` blocks interaction. `Input` and `Textarea` bind `*string`; `Password(true)` masks the displayed string per Unicode grapheme and blocks copy/cut, but not paste. The editor stores cursor and anchor in UTF-8 byte offsets at extended-grapheme boundaries. Left/Right and selections are **logical**, not visual. Primary pointer press places the caret at the nearest shaped grapheme boundary, Shift+press extends from the existing anchor, and captured dragging extends even outside the control. A second press on the same control within 500 ms and 4 logical pixels selects exactly the word-iterator segment under that boundary; at a boundary the segment to the right wins, including whitespace or punctuation segments. An unrelated press resets this sequence. Pointer hit testing requires a measured text layout. Textarea Up/Down traverse wrapped visual lines at a retained preferred x; horizontal movement and edits reset it. The per-identity editor viewport scrolls vertically in Textarea and horizontally in unwrapped Input to keep the caret visible, clamping at the measured extent. Focused editors emit visual cluster highlight rectangles and a one-pixel caret inside the editor clip. At LTR/RTL run boundaries and soft wraps, caret painting, scrolling and preedit positioning use the preceding character's trailing edge. Up/Down navigation treats a shared soft-wrap offset as belonging to the following line. Password geometry maps offsets by grapheme, like the displayed mask. No undo/redo is implemented.
12 12
13 `internal/edit` supplies the headless clipboard (`ReadText(limit)`, `WriteText`) and IME (`Enable`, `Disable`, `Surrounding`, `CursorRect`, `ContentType`) ports. A read is limited to 1 MiB and rejected unless UTF-8. IME batches represent preedit, commit and delete-surrounding relative to the UTF-8 cursor, processed at done. The preedit is shaped inline at the model cursor with an underline and its own cursor commands, without changing the model or its normal text run until commit. Password preedit is masked per grapheme before shaping or entering the display list. Ordinary keyboard text is inserted while an IME port is active, except while a preedit is shown; an input method that grabs the keyboard receives its keys instead of the editor. The ports do not negotiate MIME types, own FDs, or provide a Wayland adapter.13 `internal/edit` supplies the headless clipboard (`ReadText(limit)`, `WriteText`) and IME (`Enable`, `Disable`, `Surrounding`, `CursorRect`, `ContentType`) ports. A read is limited to 1 MiB and rejected unless UTF-8. IME batches represent preedit, commit and delete-surrounding relative to the UTF-8 cursor, processed at done. The preedit is shaped inline at the model cursor with an underline and its own cursor commands, without changing the model or its normal text run until commit. Password preedit is masked per grapheme before shaping or entering the display list. Ordinary keyboard text is inserted while an IME port is active, except while a preedit is shown; an input method that grabs the keyboard receives its keys instead of the editor. The ports do not negotiate MIME types, own FDs, or provide a Wayland adapter.
14 14
M edit_geometry.go +143 -11
@@ -10,9 +10,14 @@ import (
1010 "github.com/go-text/typesetting/di"
1111 )
1212
13+// textBoundary is one painted edge of a grapheme cluster. trailing marks the
14+// far edge of the character that precedes byteOffset; otherwise it is the
15+// leading edge of the character that follows. line indexes layout.Result.Lines.
1316 type textBoundary struct {
1417 byteOffset int
1518 x, y float64
19+ line int
20+ trailing bool
1621 }
1722
1823 // textBoundaries projects editor byte boundaries onto laid-out glyph clusters.
@@ -74,10 +79,10 @@ func textBoundaries(value string, n *layout.Result, display []layout.Command) []
7479 a, b = b, a
7580 }
7681 if offset, ok := runeAt[k]; ok {
77- current = append(current, textBoundary{offset, a, top})
82+ current = append(current, textBoundary{byteOffset: offset, x: a, y: top, line: len(lines)})
7883 }
7984 if offset, ok := runeAt[end]; ok {
80- current = append(current, textBoundary{offset, b, top})
85+ current = append(current, textBoundary{byteOffset: offset, x: b, y: top, line: len(lines), trailing: true})
8186 }
8287 }
8388 }
@@ -93,7 +98,7 @@ func textBoundaries(value string, n *layout.Result, display []layout.Command) []
9398 offset++
9499 }
95100 }
96- current = append(current, textBoundary{offset, n.Content.X, top})
101+ current = append(current, textBoundary{byteOffset: offset, x: n.Content.X, y: top, line: len(lines)})
97102 }
98103 lines = append(lines, current)
99104 top += line.Height
@@ -101,25 +106,152 @@ func textBoundaries(value string, n *layout.Result, display []layout.Command) []
101106 return lines
102107 }
103108
104-func nearestTextBoundary(value string, n *layout.Result, display []layout.Command, x, y float64) int {
105- lines := textBoundaries(value, n, display)
106- if len(lines) == 0 {
109+// displayText is the text an editor shows for a logical value, with the
110+// mapping between their byte offsets. A password shows one bullet per grapheme
111+// cluster, so logical and shown offsets differ; otherwise they are identical.
112+// It is the only place that maps password offsets.
113+type displayText struct {
114+ shown string
115+ logical, display []int // grapheme boundaries of the value and of shown; nil when identical
116+}
117+
118+func newDisplayText(value string, password bool) displayText {
119+ if !password {
120+ return displayText{shown: value}
121+ }
122+ shown := (&edit.State{Value: value}).Mask()
123+ return displayText{shown: shown, logical: edit.Boundaries(value), display: edit.Boundaries(shown)}
124+}
125+
126+// toShown maps a logical byte offset to the shown string. An offset inside a
127+// grapheme cluster maps to the start of that cluster.
128+func (d displayText) toShown(offset int) int {
129+ if d.logical == nil {
130+ return offset
131+ }
132+ i := sort.SearchInts(d.logical, offset+1) - 1
133+ if i < 0 {
134+ i = 0
135+ }
136+ return d.display[i]
137+}
138+
139+// toLogical maps a byte offset of the shown string back to the value.
140+func (d displayText) toLogical(offset int) int {
141+ if d.logical == nil {
142+ return offset
143+ }
144+ i := sort.SearchInts(d.display, offset+1) - 1
145+ if i < 0 {
146+ i = 0
147+ }
148+ return d.logical[i]
149+}
150+
151+// editorGeometry is the single owner of where editor byte offsets sit on
152+// screen. Every caller (pointer placement, vertical movement, selection and
153+// caret painting, caret scrolling, preedit origin) reads it, so they agree.
154+// Boundary offsets are logical value offsets, password or not.
155+//
156+// Caret policy: a boundary can have several visual positions (bidi run
157+// changes, soft wraps). The caret sits at the trailing edge of the character
158+// before the offset; the leading edge of the following character is used only
159+// when no character precedes the offset.
160+type editorGeometry struct {
161+ node *layout.Result
162+ lines [][]textBoundary
163+}
164+
165+func newEditorGeometry(s *edit.State, n *layout.Result, display []layout.Command, password bool) editorGeometry {
166+ text := newDisplayText(s.Value, password)
167+ lines := textBoundaries(text.shown, n, display)
168+ if text.logical != nil {
169+ for _, line := range lines {
170+ for i := range line {
171+ line[i].byteOffset = text.toLogical(line[i].byteOffset)
172+ }
173+ }
174+ }
175+ return editorGeometry{node: n, lines: lines}
176+}
177+
178+// caret returns the visual caret position for a logical offset.
179+func (g editorGeometry) caret(offset int) (textBoundary, bool) {
180+ var leading *textBoundary
181+ for i := range g.lines {
182+ b, trailing := preferredBoundary(g.lines[i], offset)
183+ if b == nil {
184+ continue
185+ }
186+ if trailing {
187+ return *b, true
188+ }
189+ if leading == nil {
190+ leading = b
191+ }
192+ }
193+ if leading == nil {
194+ return textBoundary{}, false
195+ }
196+ return *leading, true
197+}
198+
199+// caretOnLine applies the caret policy within one visual line.
200+func (g editorGeometry) caretOnLine(line, offset int) (textBoundary, bool) {
201+ b, _ := preferredBoundary(g.lines[line], offset)
202+ if b == nil {
203+ return textBoundary{}, false
204+ }
205+ return *b, true
206+}
207+
208+// preferredBoundary picks the trailing edge of the preceding character over
209+// the leading edge of the following one, else the first leading edge.
210+func preferredBoundary(line []textBoundary, offset int) (*textBoundary, bool) {
211+ var leading *textBoundary
212+ for j := range line {
213+ b := &line[j]
214+ if b.byteOffset != offset {
215+ continue
216+ }
217+ if b.trailing {
218+ return b, true
219+ }
220+ if leading == nil {
221+ leading = b
222+ }
223+ }
224+ return leading, false
225+}
226+
227+// lineHeight is the height of a visual line, falling back to the content box.
228+func (g editorGeometry) lineHeight(line int) float64 {
229+ if line >= 0 && line < len(g.node.Lines) {
230+ return g.node.Lines[line].Height
231+ }
232+ return g.node.Content.H
233+}
234+
235+// nearest returns the logical offset whose boundary is horizontally closest
236+// to (x, y) on the visual line under y.
237+func (g editorGeometry) nearest(x, y float64) int {
238+ if len(g.lines) == 0 {
107239 return 0
108240 }
109241 line := 0
110- top := n.Content.Y
111- for i, shaped := range n.Lines {
242+ top := g.node.Content.Y
243+ for i, shaped := range g.node.Lines {
112244 if y >= top+shaped.Height/2 {
113245 line = i
114246 }
115247 top += shaped.Height
116248 }
117- if line >= len(lines) {
118- line = len(lines) - 1
249+ if line >= len(g.lines) {
250+ line = len(g.lines) - 1
119251 }
120252 best := 0
121253 distance := math.Inf(1)
122- for _, b := range lines[line] {
254+ for _, b := range g.lines[line] {
123255 if d := math.Abs(b.x - x); d < distance {
124256 distance = d
125257 best = b.byteOffset
@@ -10,9 +10,14 @@ import (
10 "github.com/go-text/typesetting/di"10 "github.com/go-text/typesetting/di"
11 )11 )
12 12
13+// textBoundary is one painted edge of a grapheme cluster. trailing marks the
14+// far edge of the character that precedes byteOffset; otherwise it is the
15+// leading edge of the character that follows. line indexes layout.Result.Lines.
13 type textBoundary struct {16 type textBoundary struct {
14 byteOffset int17 byteOffset int
15 x, y float6418 x, y float64
19+ line int
20+ trailing bool
16 }21 }
17 22
18 // textBoundaries projects editor byte boundaries onto laid-out glyph clusters.23 // textBoundaries projects editor byte boundaries onto laid-out glyph clusters.
@@ -74,10 +79,10 @@ func textBoundaries(value string, n *layout.Result, display []layout.Command) []
74 a, b = b, a79 a, b = b, a
75 }80 }
76 if offset, ok := runeAt[k]; ok {81 if offset, ok := runeAt[k]; ok {
77- current = append(current, textBoundary{offset, a, top})82+ current = append(current, textBoundary{byteOffset: offset, x: a, y: top, line: len(lines)})
78 }83 }
79 if offset, ok := runeAt[end]; ok {84 if offset, ok := runeAt[end]; ok {
80- current = append(current, textBoundary{offset, b, top})85+ current = append(current, textBoundary{byteOffset: offset, x: b, y: top, line: len(lines), trailing: true})
81 }86 }
82 }87 }
83 }88 }
@@ -93,7 +98,7 @@ func textBoundaries(value string, n *layout.Result, display []layout.Command) []
93 offset++98 offset++
94 }99 }
95 }100 }
96- current = append(current, textBoundary{offset, n.Content.X, top})101+ current = append(current, textBoundary{byteOffset: offset, x: n.Content.X, y: top, line: len(lines)})
97 }102 }
98 lines = append(lines, current)103 lines = append(lines, current)
99 top += line.Height104 top += line.Height
@@ -101,25 +106,152 @@ func textBoundaries(value string, n *layout.Result, display []layout.Command) []
101 return lines106 return lines
102 }107 }
103 108
104-func nearestTextBoundary(value string, n *layout.Result, display []layout.Command, x, y float64) int {109+// displayText is the text an editor shows for a logical value, with the
105- lines := textBoundaries(value, n, display)110+// mapping between their byte offsets. A password shows one bullet per grapheme
106- if len(lines) == 0 {111+// cluster, so logical and shown offsets differ; otherwise they are identical.
112+// It is the only place that maps password offsets.
113+type displayText struct {
114+ shown string
115+ logical, display []int // grapheme boundaries of the value and of shown; nil when identical
116+}
117+
118+func newDisplayText(value string, password bool) displayText {
119+ if !password {
120+ return displayText{shown: value}
121+ }
122+ shown := (&edit.State{Value: value}).Mask()
123+ return displayText{shown: shown, logical: edit.Boundaries(value), display: edit.Boundaries(shown)}
124+}
125+
126+// toShown maps a logical byte offset to the shown string. An offset inside a
127+// grapheme cluster maps to the start of that cluster.
128+func (d displayText) toShown(offset int) int {
129+ if d.logical == nil {
130+ return offset
131+ }
132+ i := sort.SearchInts(d.logical, offset+1) - 1
133+ if i < 0 {
134+ i = 0
135+ }
136+ return d.display[i]
137+}
138+
139+// toLogical maps a byte offset of the shown string back to the value.
140+func (d displayText) toLogical(offset int) int {
141+ if d.logical == nil {
142+ return offset
143+ }
144+ i := sort.SearchInts(d.display, offset+1) - 1
145+ if i < 0 {
146+ i = 0
147+ }
148+ return d.logical[i]
149+}
150+
151+// editorGeometry is the single owner of where editor byte offsets sit on
152+// screen. Every caller (pointer placement, vertical movement, selection and
153+// caret painting, caret scrolling, preedit origin) reads it, so they agree.
154+// Boundary offsets are logical value offsets, password or not.
155+//
156+// Caret policy: a boundary can have several visual positions (bidi run
157+// changes, soft wraps). The caret sits at the trailing edge of the character
158+// before the offset; the leading edge of the following character is used only
159+// when no character precedes the offset.
160+type editorGeometry struct {
161+ node *layout.Result
162+ lines [][]textBoundary
163+}
164+
165+func newEditorGeometry(s *edit.State, n *layout.Result, display []layout.Command, password bool) editorGeometry {
166+ text := newDisplayText(s.Value, password)
167+ lines := textBoundaries(text.shown, n, display)
168+ if text.logical != nil {
169+ for _, line := range lines {
170+ for i := range line {
171+ line[i].byteOffset = text.toLogical(line[i].byteOffset)
172+ }
173+ }
174+ }
175+ return editorGeometry{node: n, lines: lines}
176+}
177+
178+// caret returns the visual caret position for a logical offset.
179+func (g editorGeometry) caret(offset int) (textBoundary, bool) {
180+ var leading *textBoundary
181+ for i := range g.lines {
182+ b, trailing := preferredBoundary(g.lines[i], offset)
183+ if b == nil {
184+ continue
185+ }
186+ if trailing {
187+ return *b, true
188+ }
189+ if leading == nil {
190+ leading = b
191+ }
192+ }
193+ if leading == nil {
194+ return textBoundary{}, false
195+ }
196+ return *leading, true
197+}
198+
199+// caretOnLine applies the caret policy within one visual line.
200+func (g editorGeometry) caretOnLine(line, offset int) (textBoundary, bool) {
201+ b, _ := preferredBoundary(g.lines[line], offset)
202+ if b == nil {
203+ return textBoundary{}, false
204+ }
205+ return *b, true
206+}
207+
208+// preferredBoundary picks the trailing edge of the preceding character over
209+// the leading edge of the following one, else the first leading edge.
210+func preferredBoundary(line []textBoundary, offset int) (*textBoundary, bool) {
211+ var leading *textBoundary
212+ for j := range line {
213+ b := &line[j]
214+ if b.byteOffset != offset {
215+ continue
216+ }
217+ if b.trailing {
218+ return b, true
219+ }
220+ if leading == nil {
221+ leading = b
222+ }
223+ }
224+ return leading, false
225+}
226+
227+// lineHeight is the height of a visual line, falling back to the content box.
228+func (g editorGeometry) lineHeight(line int) float64 {
229+ if line >= 0 && line < len(g.node.Lines) {
230+ return g.node.Lines[line].Height
231+ }
232+ return g.node.Content.H
233+}
234+
235+// nearest returns the logical offset whose boundary is horizontally closest
236+// to (x, y) on the visual line under y.
237+func (g editorGeometry) nearest(x, y float64) int {
238+ if len(g.lines) == 0 {
107 return 0239 return 0
108 }240 }
109 line := 0241 line := 0
110- top := n.Content.Y242+ top := g.node.Content.Y
111- for i, shaped := range n.Lines {243+ for i, shaped := range g.node.Lines {
112 if y >= top+shaped.Height/2 {244 if y >= top+shaped.Height/2 {
113 line = i245 line = i
114 }246 }
115 top += shaped.Height247 top += shaped.Height
116 }248 }
117- if line >= len(lines) {249+ if line >= len(g.lines) {
118- line = len(lines) - 1250+ line = len(g.lines) - 1
119 }251 }
120 best := 0252 best := 0
121 distance := math.Inf(1)253 distance := math.Inf(1)
122- for _, b := range lines[line] {254+ for _, b := range g.lines[line] {
123 if d := math.Abs(b.x - x); d < distance {255 if d := math.Abs(b.x - x); d < distance {
124 distance = d256 distance = d
125 best = b.byteOffset257 best = b.byteOffset
A edit_geometry_test.go +391 -0
@@ -0,0 +1,391 @@
1+package nefergui
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "math"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/nefergui/internal/css"
12+ "github.com/bnema/nefergui/internal/edit"
13+ "github.com/bnema/nefergui/internal/layout"
14+ "github.com/bnema/nefergui/internal/text"
15+ "github.com/go-text/typesetting/di"
16+)
17+
18+func geometryRuntime(t *testing.T, sheet string) *runtime {
19+ t.Helper()
20+ catalog, err := text.Load(text.DirectorySource("testdata/fonts"))
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ r := newRuntime()
25+ r.setTextEngine(text.NewEngine(catalog))
26+ r.styles = css.Compile(css.UA(), css.Parse(sheet))
27+ return r
28+}
29+
30+func displayCommand(r *runtime, id, op string) (layout.Command, bool) {
31+ for _, c := range r.output.Display {
32+ if c.ID == id && c.Op == op {
33+ return c, true
34+ }
35+ }
36+ return layout.Command{}, false
37+}
38+
39+// rawTrailingEdge computes, independently of editorGeometry, the x where the
40+// character at source rune index `rune` ends in reading order: its right ink
41+// edge in an LTR run and its left edge in an RTL run. It reads the shaped
42+// glyphs of the painted text command and the line's run directions only.
43+func rawTrailingEdge(t *testing.T, r *runtime, n *layout.Result, rune int) float64 {
44+ t.Helper()
45+ cmd, ok := displayCommand(r, n.ID, "text")
46+ if !ok {
47+ t.Fatal("missing text command")
48+ }
49+ ri := 0
50+ for _, line := range n.Lines {
51+ for _, shaped := range line.Runs {
52+ run := cmd.Runs[ri]
53+ ri++
54+ low, high, seen := math.Inf(1), math.Inf(-1), false
55+ for _, g := range run.Glyphs {
56+ if g.Cluster == rune {
57+ seen = true
58+ low = math.Min(low, g.X)
59+ high = math.Max(high, g.X+g.Advance)
60+ }
61+ }
62+ if !seen {
63+ continue
64+ }
65+ if shaped.Direction == di.DirectionRTL {
66+ return low
67+ }
68+ return high
69+ }
70+ }
71+ t.Fatalf("no glyph for rune %d", rune)
72+ return 0
73+}
74+
75+func TestDisplayTextMapsPasswordGraphemes(t *testing.T) {
76+ value := "e\u0301猫a"
77+ d := newDisplayText(value, true)
78+ if d.shown != "•••" {
79+ t.Fatalf("shown %q", d.shown)
80+ }
81+ bullet := len("•")
82+ for _, c := range []struct{ logical, shown int }{
83+ {0, 0}, {len("e\u0301"), bullet}, {len("e\u0301猫"), 2 * bullet}, {len(value), 3 * bullet},
84+ {1, 0}, // inside the first grapheme: start of its cluster
85+ } {
86+ if got := d.toShown(c.logical); got != c.shown {
87+ t.Errorf("toShown(%d) = %d, want %d", c.logical, got, c.shown)
88+ }
89+ }
90+ for _, c := range []struct{ shown, logical int }{{0, 0}, {bullet, len("e\u0301")}, {3 * bullet, len(value)}} {
91+ if got := d.toLogical(c.shown); got != c.logical {
92+ t.Errorf("toLogical(%d) = %d, want %d", c.shown, got, c.logical)
93+ }
94+ }
95+ plain := newDisplayText(value, false)
96+ if plain.shown != value || plain.toShown(3) != 3 || plain.toLogical(3) != 3 {
97+ t.Fatalf("plain text must map identically: %+v", plain)
98+ }
99+}
100+
101+// At a bidi run boundary one offset has two visual positions. Painting,
102+// scrolling and preedit must all use the preceding character's trailing edge.
103+func TestMixedDirectionCaretPolicyAgreesAcrossCallers(t *testing.T) {
104+ value := "abc مرحبا def"
105+ view := func(f *Frame) { f.Root().Input("mixed", &value) }
106+ // offset -> source rune index of the character before it.
107+ for _, c := range []struct{ offset, before int }{{len("abc "), 3}, {len("abc مرحبا"), 8}} {
108+ offset := c.offset
109+ r := geometryRuntime(t, `input {width:300px;height:28px;font-size:16px}`)
110+ r.Build(view)
111+ r.route(key("Tab"))
112+ r.Build(view)
113+ n := r.output.Tree.Children[0]
114+ r.edits[n.ID].Move(offset, false)
115+ r.edits[n.ID].Preedit = "x"
116+ r.Redraw()
117+ r.Build(view)
118+ n = r.output.Tree.Children[0]
119+
120+ g := newEditorGeometry(r.edits[n.ID], n, r.output.Display, false)
121+ var edges []textBoundary
122+ for _, b := range g.lines[0] {
123+ if b.byteOffset == offset {
124+ edges = append(edges, b)
125+ }
126+ }
127+ var leading textBoundary
128+ for _, b := range edges {
129+ if !b.trailing {
130+ leading = b
131+ }
132+ }
133+ want := rawTrailingEdge(t, r, n, c.before)
134+ if len(edges) < 2 || math.Abs(want-leading.x) < 1 {
135+ t.Fatalf("offset %d is not an ambiguous bidi boundary: %+v (preceding edge %g)", offset, edges, want)
136+ }
137+ caret, ok := displayCommand(r, n.ID, "caret")
138+ if !ok {
139+ t.Fatal("missing caret")
140+ }
141+ preedit, ok := displayCommand(r, n.ID, "preedit-text")
142+ if !ok {
143+ t.Fatal("missing preedit")
144+ }
145+ if math.Abs(caret.Rect.X-want) > 0.01 || math.Abs(preedit.Rect.X-want) > 0.01 {
146+ t.Fatalf("offset %d: caret %g preedit %g, want preceding trailing edge %g (leading %g)", offset, caret.Rect.X, preedit.Rect.X, want, leading.x)
147+ }
148+ }
149+}
150+
151+// The scroll target must be the painted caret, not another duplicate offset.
152+func TestMixedDirectionScrollFollowsPaintedCaret(t *testing.T) {
153+ value := "abc مرحبا def"
154+ view := func(f *Frame) { f.Root().Input("mixed", &value) }
155+ // The trailing edge of "abc " is left of the leading edge of the RTL run.
156+ // A content box between the two edges scrolls only if the wrong one is used.
157+ r := geometryRuntime(t, `input {width:300px;height:28px;font-size:16px}`)
158+ r.Build(view)
159+ r.route(key("Tab"))
160+ r.Build(view)
161+ n := r.output.Tree.Children[0]
162+ // Expected edges come from raw glyphs. The space before the RTL run ends at
163+ // trailing; the RTL run's reading-order start (its right ink edge) is the
164+ // other, leading duplicate of the same offset.
165+ trailing := rawTrailingEdge(t, r, n, 3)
166+ leading := math.Inf(-1)
167+ cmd, _ := displayCommand(r, n.ID, "text")
168+ for i, run := range cmd.Runs {
169+ if n.Lines[0].Runs[i].Direction == di.DirectionRTL {
170+ for _, g := range run.Glyphs {
171+ leading = math.Max(leading, g.X+g.Advance)
172+ }
173+ }
174+ }
175+ if trailing >= leading {
176+ t.Fatalf("fixture changed: trailing %g leading %g", trailing, leading)
177+ }
178+ // Content wide enough for the preceding character's trailing edge but not
179+ // for the following run's leading edge.
180+ padding := 300 - n.Content.W
181+ contentW := math.Floor((trailing + leading) / 2)
182+ r = geometryRuntime(t, fmt.Sprintf("input {height:28px;font-size:16px;width:%gpx}", contentW+padding))
183+ r.Build(view)
184+ n = r.output.Tree.Children[0]
185+ r.edits[n.ID].Move(len("abc "), false) // before focus, so no earlier scroll persists
186+ r.route(key("Tab"))
187+ r.Build(view)
188+ n = r.output.Tree.Children[0]
189+ caret, _ := displayCommand(r, n.ID, "caret")
190+ if n.ScrollX != 0 || math.Abs(caret.Rect.X-trailing) > 0.01 || caret.Rect.X > n.Content.X+n.Content.W-1 {
191+ t.Fatalf("scroll %g caret %g want unscrolled at %g; content %+v", n.ScrollX, caret.Rect.X, trailing, n.Content)
192+ }
193+}
194+
195+func TestPasswordCombiningGraphemesScrollAndPreedit(t *testing.T) {
196+ value := strings.Repeat("e\u0301", 12)
197+ view := func(f *Frame) { f.Root().Input("secret", &value, Password(true)) }
198+ r := geometryRuntime(t, `input {width:60px;height:28px;font-size:16px}`)
199+ r.Build(view)
200+ r.route(key("Tab"))
201+ r.Build(view)
202+ n := r.output.Tree.Children[0]
203+ s := r.edits[n.ID]
204+ if s.Cursor != len(value) {
205+ t.Fatalf("cursor %d", s.Cursor)
206+ }
207+ // 12 graphemes are 24 runes; counting runes used to miss the mask boundary.
208+ r.Redraw()
209+ r.Build(view)
210+ n = r.output.Tree.Children[0]
211+ if n.ScrollX <= 0 {
212+ t.Fatalf("caret at end of a long password did not scroll: %+v", n)
213+ }
214+ caret, ok := displayCommand(r, n.ID, "caret")
215+ if !ok || caret.Rect.X < n.Content.X-0.01 || caret.Rect.X > n.Content.X+n.Content.W {
216+ t.Fatalf("caret %+v outside content %+v", caret, n.Content)
217+ }
218+
219+ // Move inside the value, then compose: origin is the painted caret.
220+ mid := len(strings.Repeat("e\u0301", 4))
221+ s.Move(mid, false)
222+ s.Preedit = "e\u0301猫"
223+ s.PreeditBegin = len("e\u0301")
224+ r.Redraw()
225+ r.Build(view)
226+ n = r.output.Tree.Children[0]
227+ caret, _ = displayCommand(r, n.ID, "caret")
228+ preedit, ok := displayCommand(r, n.ID, "preedit-text")
229+ // Independent expectation: the trailing edge of the 4th bullet (4 graphemes,
230+ // 8 runes, precede the caret) in the shaped masked text.
231+ want := rawTrailingEdge(t, r, n, 3)
232+ if !ok || math.Abs(caret.Rect.X-want) > 0.01 || math.Abs(preedit.Rect.X-want) > 0.01 {
233+ t.Fatalf("caret %g preedit %g, want after 4th bullet at %g", caret.Rect.X, preedit.Rect.X, want)
234+ }
235+ if preedit.Text != "••" {
236+ t.Fatalf("preedit text %q", preedit.Text)
237+ }
238+ data, err := json.Marshal(r.output.Display)
239+ if err != nil {
240+ t.Fatal(err)
241+ }
242+ if bytes.Contains(data, []byte("e\u0301")) || bytes.Contains(data, []byte("猫")) {
243+ t.Fatalf("plaintext leaked: %s", data)
244+ }
245+ if value != strings.Repeat("e\u0301", 12) {
246+ t.Fatalf("model changed: %q", value)
247+ }
248+}
249+
250+func TestPasswordPointerPlacementUsesGraphemeOffsets(t *testing.T) {
251+ value := "e\u0301a\u0300b"
252+ view := func(f *Frame) { f.Root().Input("secret", &value, Password(true)) }
253+ r := geometryRuntime(t, `input {width:200px;height:28px;font-size:16px}`)
254+ r.Build(view)
255+ n := r.output.Tree.Children[0]
256+ g := newEditorGeometry(&edit.State{Value: value}, n, r.output.Display, true)
257+ // Bullet k (0-based) ends the k+1'th grapheme; its raw trailing edge must
258+ // place the caret after that grapheme in the logical value.
259+ for k, want := range []int{len("e\u0301"), len("e\u0301a\u0300"), len(value)} {
260+ x := rawTrailingEdge(t, r, n, k)
261+ if got := g.nearest(x, n.Content.Y+1); got != want {
262+ t.Fatalf("nearest(%g) = %d, want %d", x, got, want)
263+ }
264+ }
265+ if got := g.nearest(n.Content.X, n.Content.Y+1); got != 0 {
266+ t.Fatalf("nearest at start = %d", got)
267+ }
268+}
269+
270+const wrapValue = "one two three four five six"
271+
272+// wrapFixture builds a focused, narrow textarea and returns the source offset
273+// shared by the end of visual line 0 and the start of line 1 ("one |two").
274+// The offset comes from the shaped runs, not from editorGeometry.
275+func wrapFixture(t *testing.T, height string) (*runtime, func(*Frame), *layout.Result, int) {
276+ t.Helper()
277+ r := geometryRuntime(t, "textarea {width:45px;height:"+height+";font-size:16px}")
278+ value := wrapValue
279+ view := func(f *Frame) { f.Root().Textarea("wrapped", &value) }
280+ r.Build(view)
281+ n := r.output.Tree.Children[0]
282+ if len(n.Lines) < 3 {
283+ t.Fatalf("expected wrapped lines, got %d", len(n.Lines))
284+ }
285+ first, second := n.Lines[0].Runs, n.Lines[1].Runs
286+ shared := first[len(first)-1].End
287+ if shared == 0 || shared != second[0].Start || shared >= len(value) {
288+ t.Fatalf("lines do not share a wrap offset: %+v %+v", first, second)
289+ }
290+ r.edits[n.ID].Move(shared, false) // ASCII: rune index == byte offset
291+ r.route(key("Tab"))
292+ r.Build(view)
293+ return r, view, r.output.Tree.Children[0], shared
294+}
295+
296+// At a soft wrap the offset is the end of line 0 and the start of line 1.
297+// Painting and preedit use the end of line 0 (preceding character), and the
298+// scroll follows that same caret, while vertical navigation still treats the
299+// offset as being on the following line.
300+func TestSoftWrapCaretPolicyAgreesAcrossCallers(t *testing.T) {
301+ r, view, n, shared := wrapFixture(t, "100px")
302+ s := r.edits[n.ID]
303+ if s.Cursor != shared {
304+ t.Fatalf("cursor %d want %d", s.Cursor, shared)
305+ }
306+ wantX := rawTrailingEdge(t, r, n, shared-1)
307+ wantY := n.Content.Y
308+ nextLineY := n.Content.Y + n.Lines[0].Height
309+ s.Preedit = "x"
310+ r.Redraw()
311+ r.Build(view)
312+ n = r.output.Tree.Children[0]
313+ caret, ok := displayCommand(r, n.ID, "caret")
314+ if !ok {
315+ t.Fatal("missing caret")
316+ }
317+ preedit, ok := displayCommand(r, n.ID, "preedit-text")
318+ if !ok {
319+ t.Fatal("missing preedit")
320+ }
321+ for name, rect := range map[string]layout.Rect{"caret": caret.Rect, "preedit": preedit.Rect} {
322+ if math.Abs(rect.X-wantX) > 0.01 || math.Abs(rect.Y-wantY) > 0.01 || rect.Y == nextLineY {
323+ t.Errorf("%s at (%g,%g), want end of preceding line (%g,%g)", name, rect.X, rect.Y, wantX, wantY)
324+ }
325+ }
326+ if caret.Rect.H != n.Lines[0].Height {
327+ t.Errorf("caret height %g want line height %g", caret.Rect.H, n.Lines[0].Height)
328+ }
329+}
330+
331+func TestSoftWrapScrollFollowsPrecedingLineCaret(t *testing.T) {
332+ // Content holds exactly one line. The caret at the wrap offset is on line
333+ // 0, so nothing scrolls; following line 1 instead would scroll.
334+ r, _, n, _ := wrapFixture(t, "22px")
335+ if n.Content.H >= 2*n.Lines[0].Height {
336+ t.Fatalf("fixture shows more than one line: %+v", n.Content)
337+ }
338+ if n.ScrollY != 0 {
339+ t.Fatalf("scrolled to the following line: %g", n.ScrollY)
340+ }
341+ if caret, ok := displayCommand(r, n.ID, "caret"); !ok || caret.Rect.Y != n.Content.Y {
342+ t.Fatalf("caret %+v content %+v", caret, n.Content)
343+ }
344+}
345+
346+func TestSoftWrapVerticalNavigationKeepsFollowingLinePolicy(t *testing.T) {
347+ // Down from the shared offset moves off line 1 (to the start of line 2),
348+ // not off line 0; up moves off line 1 to line 0, whichever the caret paints.
349+ r, view, n, shared := wrapFixture(t, "100px")
350+ s := r.edits[n.ID]
351+ lineEnd := n.Lines[1].Runs[len(n.Lines[1].Runs)-1].End
352+ r.route(key("Down"))
353+ r.Build(view)
354+ if s.Cursor != lineEnd {
355+ t.Fatalf("down from %d = %d, want start of line 2 at %d", shared, s.Cursor, lineEnd)
356+ }
357+ if !s.PreferredXValid || s.PreferredX != n.Content.X {
358+ t.Fatalf("preferred x %v %g, want line-1 start %g", s.PreferredXValid, s.PreferredX, n.Content.X)
359+ }
360+ s.Move(shared, false)
361+ s.PreferredXValid = false
362+ r.route(key("Up"))
363+ r.Build(view)
364+ if s.Cursor != 0 {
365+ t.Fatalf("up from %d = %d, want start of line 0", shared, s.Cursor)
366+ }
367+}
368+
369+// Scrolling uses the height of the caret's own line, not line 0's.
370+func TestScrollCaretUsesCaretLineHeight(t *testing.T) {
371+ r, _, n, _ := wrapFixture(t, "100px")
372+ id := n.ID
373+ // Line 0 is short and line 1 is tall. The caret is at the end of line 1.
374+ n.Lines[0].Height = 10
375+ n.Lines[1].Height = 30
376+ n.Content.H = 30
377+ n.ContentSize.H = 200
378+ s := r.edits[id]
379+ s.Move(n.Lines[1].Runs[len(n.Lines[1].Runs)-1].End-1, false) // before "two"'s trailing space edge: on line 1
380+ if r.state.scroll != nil {
381+ delete(r.state.scroll, id)
382+ }
383+ if !r.scrollCaret(r.committed, &r.output) {
384+ t.Fatal("caret below the viewport did not scroll")
385+ }
386+ // Caret line 1 spans y 10..40 in a 30px viewport: scroll by 10. Using line 0's
387+ // height (10) would end at 20 and not scroll at all.
388+ if got := r.state.scroll[id].H; math.Abs(got-10) > 0.01 {
389+ t.Fatalf("scroll %g, want 10 (caret line bottom 40 - viewport 30)", got)
390+ }
391+}
@@ -0,0 +1,391 @@
1+package nefergui
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "math"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/nefergui/internal/css"
12+ "github.com/bnema/nefergui/internal/edit"
13+ "github.com/bnema/nefergui/internal/layout"
14+ "github.com/bnema/nefergui/internal/text"
15+ "github.com/go-text/typesetting/di"
16+)
17+
18+func geometryRuntime(t *testing.T, sheet string) *runtime {
19+ t.Helper()
20+ catalog, err := text.Load(text.DirectorySource("testdata/fonts"))
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ r := newRuntime()
25+ r.setTextEngine(text.NewEngine(catalog))
26+ r.styles = css.Compile(css.UA(), css.Parse(sheet))
27+ return r
28+}
29+
30+func displayCommand(r *runtime, id, op string) (layout.Command, bool) {
31+ for _, c := range r.output.Display {
32+ if c.ID == id && c.Op == op {
33+ return c, true
34+ }
35+ }
36+ return layout.Command{}, false
37+}
38+
39+// rawTrailingEdge computes, independently of editorGeometry, the x where the
40+// character at source rune index `rune` ends in reading order: its right ink
41+// edge in an LTR run and its left edge in an RTL run. It reads the shaped
42+// glyphs of the painted text command and the line's run directions only.
43+func rawTrailingEdge(t *testing.T, r *runtime, n *layout.Result, rune int) float64 {
44+ t.Helper()
45+ cmd, ok := displayCommand(r, n.ID, "text")
46+ if !ok {
47+ t.Fatal("missing text command")
48+ }
49+ ri := 0
50+ for _, line := range n.Lines {
51+ for _, shaped := range line.Runs {
52+ run := cmd.Runs[ri]
53+ ri++
54+ low, high, seen := math.Inf(1), math.Inf(-1), false
55+ for _, g := range run.Glyphs {
56+ if g.Cluster == rune {
57+ seen = true
58+ low = math.Min(low, g.X)
59+ high = math.Max(high, g.X+g.Advance)
60+ }
61+ }
62+ if !seen {
63+ continue
64+ }
65+ if shaped.Direction == di.DirectionRTL {
66+ return low
67+ }
68+ return high
69+ }
70+ }
71+ t.Fatalf("no glyph for rune %d", rune)
72+ return 0
73+}
74+
75+func TestDisplayTextMapsPasswordGraphemes(t *testing.T) {
76+ value := "e\u0301猫a"
77+ d := newDisplayText(value, true)
78+ if d.shown != "•••" {
79+ t.Fatalf("shown %q", d.shown)
80+ }
81+ bullet := len("•")
82+ for _, c := range []struct{ logical, shown int }{
83+ {0, 0}, {len("e\u0301"), bullet}, {len("e\u0301猫"), 2 * bullet}, {len(value), 3 * bullet},
84+ {1, 0}, // inside the first grapheme: start of its cluster
85+ } {
86+ if got := d.toShown(c.logical); got != c.shown {
87+ t.Errorf("toShown(%d) = %d, want %d", c.logical, got, c.shown)
88+ }
89+ }
90+ for _, c := range []struct{ shown, logical int }{{0, 0}, {bullet, len("e\u0301")}, {3 * bullet, len(value)}} {
91+ if got := d.toLogical(c.shown); got != c.logical {
92+ t.Errorf("toLogical(%d) = %d, want %d", c.shown, got, c.logical)
93+ }
94+ }
95+ plain := newDisplayText(value, false)
96+ if plain.shown != value || plain.toShown(3) != 3 || plain.toLogical(3) != 3 {
97+ t.Fatalf("plain text must map identically: %+v", plain)
98+ }
99+}
100+
101+// At a bidi run boundary one offset has two visual positions. Painting,
102+// scrolling and preedit must all use the preceding character's trailing edge.
103+func TestMixedDirectionCaretPolicyAgreesAcrossCallers(t *testing.T) {
104+ value := "abc مرحبا def"
105+ view := func(f *Frame) { f.Root().Input("mixed", &value) }
106+ // offset -> source rune index of the character before it.
107+ for _, c := range []struct{ offset, before int }{{len("abc "), 3}, {len("abc مرحبا"), 8}} {
108+ offset := c.offset
109+ r := geometryRuntime(t, `input {width:300px;height:28px;font-size:16px}`)
110+ r.Build(view)
111+ r.route(key("Tab"))
112+ r.Build(view)
113+ n := r.output.Tree.Children[0]
114+ r.edits[n.ID].Move(offset, false)
115+ r.edits[n.ID].Preedit = "x"
116+ r.Redraw()
117+ r.Build(view)
118+ n = r.output.Tree.Children[0]
119+
120+ g := newEditorGeometry(r.edits[n.ID], n, r.output.Display, false)
121+ var edges []textBoundary
122+ for _, b := range g.lines[0] {
123+ if b.byteOffset == offset {
124+ edges = append(edges, b)
125+ }
126+ }
127+ var leading textBoundary
128+ for _, b := range edges {
129+ if !b.trailing {
130+ leading = b
131+ }
132+ }
133+ want := rawTrailingEdge(t, r, n, c.before)
134+ if len(edges) < 2 || math.Abs(want-leading.x) < 1 {
135+ t.Fatalf("offset %d is not an ambiguous bidi boundary: %+v (preceding edge %g)", offset, edges, want)
136+ }
137+ caret, ok := displayCommand(r, n.ID, "caret")
138+ if !ok {
139+ t.Fatal("missing caret")
140+ }
141+ preedit, ok := displayCommand(r, n.ID, "preedit-text")
142+ if !ok {
143+ t.Fatal("missing preedit")
144+ }
145+ if math.Abs(caret.Rect.X-want) > 0.01 || math.Abs(preedit.Rect.X-want) > 0.01 {
146+ t.Fatalf("offset %d: caret %g preedit %g, want preceding trailing edge %g (leading %g)", offset, caret.Rect.X, preedit.Rect.X, want, leading.x)
147+ }
148+ }
149+}
150+
151+// The scroll target must be the painted caret, not another duplicate offset.
152+func TestMixedDirectionScrollFollowsPaintedCaret(t *testing.T) {
153+ value := "abc مرحبا def"
154+ view := func(f *Frame) { f.Root().Input("mixed", &value) }
155+ // The trailing edge of "abc " is left of the leading edge of the RTL run.
156+ // A content box between the two edges scrolls only if the wrong one is used.
157+ r := geometryRuntime(t, `input {width:300px;height:28px;font-size:16px}`)
158+ r.Build(view)
159+ r.route(key("Tab"))
160+ r.Build(view)
161+ n := r.output.Tree.Children[0]
162+ // Expected edges come from raw glyphs. The space before the RTL run ends at
163+ // trailing; the RTL run's reading-order start (its right ink edge) is the
164+ // other, leading duplicate of the same offset.
165+ trailing := rawTrailingEdge(t, r, n, 3)
166+ leading := math.Inf(-1)
167+ cmd, _ := displayCommand(r, n.ID, "text")
168+ for i, run := range cmd.Runs {
169+ if n.Lines[0].Runs[i].Direction == di.DirectionRTL {
170+ for _, g := range run.Glyphs {
171+ leading = math.Max(leading, g.X+g.Advance)
172+ }
173+ }
174+ }
175+ if trailing >= leading {
176+ t.Fatalf("fixture changed: trailing %g leading %g", trailing, leading)
177+ }
178+ // Content wide enough for the preceding character's trailing edge but not
179+ // for the following run's leading edge.
180+ padding := 300 - n.Content.W
181+ contentW := math.Floor((trailing + leading) / 2)
182+ r = geometryRuntime(t, fmt.Sprintf("input {height:28px;font-size:16px;width:%gpx}", contentW+padding))
183+ r.Build(view)
184+ n = r.output.Tree.Children[0]
185+ r.edits[n.ID].Move(len("abc "), false) // before focus, so no earlier scroll persists
186+ r.route(key("Tab"))
187+ r.Build(view)
188+ n = r.output.Tree.Children[0]
189+ caret, _ := displayCommand(r, n.ID, "caret")
190+ if n.ScrollX != 0 || math.Abs(caret.Rect.X-trailing) > 0.01 || caret.Rect.X > n.Content.X+n.Content.W-1 {
191+ t.Fatalf("scroll %g caret %g want unscrolled at %g; content %+v", n.ScrollX, caret.Rect.X, trailing, n.Content)
192+ }
193+}
194+
195+func TestPasswordCombiningGraphemesScrollAndPreedit(t *testing.T) {
196+ value := strings.Repeat("e\u0301", 12)
197+ view := func(f *Frame) { f.Root().Input("secret", &value, Password(true)) }
198+ r := geometryRuntime(t, `input {width:60px;height:28px;font-size:16px}`)
199+ r.Build(view)
200+ r.route(key("Tab"))
201+ r.Build(view)
202+ n := r.output.Tree.Children[0]
203+ s := r.edits[n.ID]
204+ if s.Cursor != len(value) {
205+ t.Fatalf("cursor %d", s.Cursor)
206+ }
207+ // 12 graphemes are 24 runes; counting runes used to miss the mask boundary.
208+ r.Redraw()
209+ r.Build(view)
210+ n = r.output.Tree.Children[0]
211+ if n.ScrollX <= 0 {
212+ t.Fatalf("caret at end of a long password did not scroll: %+v", n)
213+ }
214+ caret, ok := displayCommand(r, n.ID, "caret")
215+ if !ok || caret.Rect.X < n.Content.X-0.01 || caret.Rect.X > n.Content.X+n.Content.W {
216+ t.Fatalf("caret %+v outside content %+v", caret, n.Content)
217+ }
218+
219+ // Move inside the value, then compose: origin is the painted caret.
220+ mid := len(strings.Repeat("e\u0301", 4))
221+ s.Move(mid, false)
222+ s.Preedit = "e\u0301猫"
223+ s.PreeditBegin = len("e\u0301")
224+ r.Redraw()
225+ r.Build(view)
226+ n = r.output.Tree.Children[0]
227+ caret, _ = displayCommand(r, n.ID, "caret")
228+ preedit, ok := displayCommand(r, n.ID, "preedit-text")
229+ // Independent expectation: the trailing edge of the 4th bullet (4 graphemes,
230+ // 8 runes, precede the caret) in the shaped masked text.
231+ want := rawTrailingEdge(t, r, n, 3)
232+ if !ok || math.Abs(caret.Rect.X-want) > 0.01 || math.Abs(preedit.Rect.X-want) > 0.01 {
233+ t.Fatalf("caret %g preedit %g, want after 4th bullet at %g", caret.Rect.X, preedit.Rect.X, want)
234+ }
235+ if preedit.Text != "••" {
236+ t.Fatalf("preedit text %q", preedit.Text)
237+ }
238+ data, err := json.Marshal(r.output.Display)
239+ if err != nil {
240+ t.Fatal(err)
241+ }
242+ if bytes.Contains(data, []byte("e\u0301")) || bytes.Contains(data, []byte("猫")) {
243+ t.Fatalf("plaintext leaked: %s", data)
244+ }
245+ if value != strings.Repeat("e\u0301", 12) {
246+ t.Fatalf("model changed: %q", value)
247+ }
248+}
249+
250+func TestPasswordPointerPlacementUsesGraphemeOffsets(t *testing.T) {
251+ value := "e\u0301a\u0300b"
252+ view := func(f *Frame) { f.Root().Input("secret", &value, Password(true)) }
253+ r := geometryRuntime(t, `input {width:200px;height:28px;font-size:16px}`)
254+ r.Build(view)
255+ n := r.output.Tree.Children[0]
256+ g := newEditorGeometry(&edit.State{Value: value}, n, r.output.Display, true)
257+ // Bullet k (0-based) ends the k+1'th grapheme; its raw trailing edge must
258+ // place the caret after that grapheme in the logical value.
259+ for k, want := range []int{len("e\u0301"), len("e\u0301a\u0300"), len(value)} {
260+ x := rawTrailingEdge(t, r, n, k)
261+ if got := g.nearest(x, n.Content.Y+1); got != want {
262+ t.Fatalf("nearest(%g) = %d, want %d", x, got, want)
263+ }
264+ }
265+ if got := g.nearest(n.Content.X, n.Content.Y+1); got != 0 {
266+ t.Fatalf("nearest at start = %d", got)
267+ }
268+}
269+
270+const wrapValue = "one two three four five six"
271+
272+// wrapFixture builds a focused, narrow textarea and returns the source offset
273+// shared by the end of visual line 0 and the start of line 1 ("one |two").
274+// The offset comes from the shaped runs, not from editorGeometry.
275+func wrapFixture(t *testing.T, height string) (*runtime, func(*Frame), *layout.Result, int) {
276+ t.Helper()
277+ r := geometryRuntime(t, "textarea {width:45px;height:"+height+";font-size:16px}")
278+ value := wrapValue
279+ view := func(f *Frame) { f.Root().Textarea("wrapped", &value) }
280+ r.Build(view)
281+ n := r.output.Tree.Children[0]
282+ if len(n.Lines) < 3 {
283+ t.Fatalf("expected wrapped lines, got %d", len(n.Lines))
284+ }
285+ first, second := n.Lines[0].Runs, n.Lines[1].Runs
286+ shared := first[len(first)-1].End
287+ if shared == 0 || shared != second[0].Start || shared >= len(value) {
288+ t.Fatalf("lines do not share a wrap offset: %+v %+v", first, second)
289+ }
290+ r.edits[n.ID].Move(shared, false) // ASCII: rune index == byte offset
291+ r.route(key("Tab"))
292+ r.Build(view)
293+ return r, view, r.output.Tree.Children[0], shared
294+}
295+
296+// At a soft wrap the offset is the end of line 0 and the start of line 1.
297+// Painting and preedit use the end of line 0 (preceding character), and the
298+// scroll follows that same caret, while vertical navigation still treats the
299+// offset as being on the following line.
300+func TestSoftWrapCaretPolicyAgreesAcrossCallers(t *testing.T) {
301+ r, view, n, shared := wrapFixture(t, "100px")
302+ s := r.edits[n.ID]
303+ if s.Cursor != shared {
304+ t.Fatalf("cursor %d want %d", s.Cursor, shared)
305+ }
306+ wantX := rawTrailingEdge(t, r, n, shared-1)
307+ wantY := n.Content.Y
308+ nextLineY := n.Content.Y + n.Lines[0].Height
309+ s.Preedit = "x"
310+ r.Redraw()
311+ r.Build(view)
312+ n = r.output.Tree.Children[0]
313+ caret, ok := displayCommand(r, n.ID, "caret")
314+ if !ok {
315+ t.Fatal("missing caret")
316+ }
317+ preedit, ok := displayCommand(r, n.ID, "preedit-text")
318+ if !ok {
319+ t.Fatal("missing preedit")
320+ }
321+ for name, rect := range map[string]layout.Rect{"caret": caret.Rect, "preedit": preedit.Rect} {
322+ if math.Abs(rect.X-wantX) > 0.01 || math.Abs(rect.Y-wantY) > 0.01 || rect.Y == nextLineY {
323+ t.Errorf("%s at (%g,%g), want end of preceding line (%g,%g)", name, rect.X, rect.Y, wantX, wantY)
324+ }
325+ }
326+ if caret.Rect.H != n.Lines[0].Height {
327+ t.Errorf("caret height %g want line height %g", caret.Rect.H, n.Lines[0].Height)
328+ }
329+}
330+
331+func TestSoftWrapScrollFollowsPrecedingLineCaret(t *testing.T) {
332+ // Content holds exactly one line. The caret at the wrap offset is on line
333+ // 0, so nothing scrolls; following line 1 instead would scroll.
334+ r, _, n, _ := wrapFixture(t, "22px")
335+ if n.Content.H >= 2*n.Lines[0].Height {
336+ t.Fatalf("fixture shows more than one line: %+v", n.Content)
337+ }
338+ if n.ScrollY != 0 {
339+ t.Fatalf("scrolled to the following line: %g", n.ScrollY)
340+ }
341+ if caret, ok := displayCommand(r, n.ID, "caret"); !ok || caret.Rect.Y != n.Content.Y {
342+ t.Fatalf("caret %+v content %+v", caret, n.Content)
343+ }
344+}
345+
346+func TestSoftWrapVerticalNavigationKeepsFollowingLinePolicy(t *testing.T) {
347+ // Down from the shared offset moves off line 1 (to the start of line 2),
348+ // not off line 0; up moves off line 1 to line 0, whichever the caret paints.
349+ r, view, n, shared := wrapFixture(t, "100px")
350+ s := r.edits[n.ID]
351+ lineEnd := n.Lines[1].Runs[len(n.Lines[1].Runs)-1].End
352+ r.route(key("Down"))
353+ r.Build(view)
354+ if s.Cursor != lineEnd {
355+ t.Fatalf("down from %d = %d, want start of line 2 at %d", shared, s.Cursor, lineEnd)
356+ }
357+ if !s.PreferredXValid || s.PreferredX != n.Content.X {
358+ t.Fatalf("preferred x %v %g, want line-1 start %g", s.PreferredXValid, s.PreferredX, n.Content.X)
359+ }
360+ s.Move(shared, false)
361+ s.PreferredXValid = false
362+ r.route(key("Up"))
363+ r.Build(view)
364+ if s.Cursor != 0 {
365+ t.Fatalf("up from %d = %d, want start of line 0", shared, s.Cursor)
366+ }
367+}
368+
369+// Scrolling uses the height of the caret's own line, not line 0's.
370+func TestScrollCaretUsesCaretLineHeight(t *testing.T) {
371+ r, _, n, _ := wrapFixture(t, "100px")
372+ id := n.ID
373+ // Line 0 is short and line 1 is tall. The caret is at the end of line 1.
374+ n.Lines[0].Height = 10
375+ n.Lines[1].Height = 30
376+ n.Content.H = 30
377+ n.ContentSize.H = 200
378+ s := r.edits[id]
379+ s.Move(n.Lines[1].Runs[len(n.Lines[1].Runs)-1].End-1, false) // before "two"'s trailing space edge: on line 1
380+ if r.state.scroll != nil {
381+ delete(r.state.scroll, id)
382+ }
383+ if !r.scrollCaret(r.committed, &r.output) {
384+ t.Fatal("caret below the viewport did not scroll")
385+ }
386+ // Caret line 1 spans y 10..40 in a 30px viewport: scroll by 10. Using line 0's
387+ // height (10) would end at 20 and not scroll at all.
388+ if got := r.state.scroll[id].H; math.Abs(got-10) > 0.01 {
389+ t.Fatalf("scroll %g, want 10 (caret line bottom 40 - viewport 30)", got)
390+ }
391+}
M edit_navigation.go +7 -8
@@ -9,8 +9,9 @@ import (
99
1010 // moveVisualLine uses the last committed wrap geometry; preferred X survives
1111 // consecutive vertical moves, but is reset by horizontal editing and pointer input.
12-func moveVisualLine(s *edit.State, n *layout.Result, display []layout.Command, down, shift bool) {
13- lines := textBoundaries(s.Value, n, display)
12+func moveVisualLine(s *edit.State, n *layout.Result, display []layout.Command, password, down, shift bool) {
13+ geometry := newEditorGeometry(s, n, display, password)
14+ lines := geometry.lines
1415 if len(lines) == 0 {
1516 return
1617 }
@@ -22,14 +23,12 @@ func moveVisualLine(s *edit.State, n *layout.Result, display []layout.Command, d
2223 break
2324 }
2425 }
25- // Prefer the following line at a shared wrap boundary.
2626 }
27+ // Navigation prefers the following line at a shared wrap boundary, unlike
28+ // the painted caret, which stays at the end of the preceding line.
2729 if !s.PreferredXValid {
28- for _, b := range lines[current] {
29- if b.byteOffset == s.Cursor {
30- s.PreferredX = b.x
31- break
32- }
30+ if b, ok := geometry.caretOnLine(current, s.Cursor); ok {
31+ s.PreferredX = b.x
3332 }
3433 s.PreferredXValid = true
3534 }
@@ -9,8 +9,9 @@ import (
9 9
10 // moveVisualLine uses the last committed wrap geometry; preferred X survives10 // moveVisualLine uses the last committed wrap geometry; preferred X survives
11 // consecutive vertical moves, but is reset by horizontal editing and pointer input.11 // consecutive vertical moves, but is reset by horizontal editing and pointer input.
12-func moveVisualLine(s *edit.State, n *layout.Result, display []layout.Command, down, shift bool) {12+func moveVisualLine(s *edit.State, n *layout.Result, display []layout.Command, password, down, shift bool) {
13- lines := textBoundaries(s.Value, n, display)13+ geometry := newEditorGeometry(s, n, display, password)
14+ lines := geometry.lines
14 if len(lines) == 0 {15 if len(lines) == 0 {
15 return16 return
16 }17 }
@@ -22,14 +23,12 @@ func moveVisualLine(s *edit.State, n *layout.Result, display []layout.Command, d
22 break23 break
23 }24 }
24 }25 }
25- // Prefer the following line at a shared wrap boundary.
26 }26 }
27+ // Navigation prefers the following line at a shared wrap boundary, unlike
28+ // the painted caret, which stays at the end of the preceding line.
27 if !s.PreferredXValid {29 if !s.PreferredXValid {
28- for _, b := range lines[current] {30+ if b, ok := geometry.caretOnLine(current, s.Cursor); ok {
29- if b.byteOffset == s.Cursor {31+ s.PreferredX = b.x
30- s.PreferredX = b.x
31- break
32- }
33 }32 }
34 s.PreferredXValid = true33 s.PreferredXValid = true
35 }34 }
M edit_paint.go +11 -43
@@ -43,37 +43,20 @@ func (r *runtime) paintEditors(root *element, out *layout.Output) {
4343 }
4444
4545 // editorDecoration maps logical grapheme selection to visual cluster spans.
46-// A bidi boundary uses the run containing the character before the cursor:
47-// later duplicate offsets must not override the first span's trailing edge.
46+// The caret follows editorGeometry's policy: at an ambiguous boundary, such as
47+// a bidi run change, the preceding character's trailing edge is used.
4848 func editorDecoration(s *edit.State, n *layout.Result, display []layout.Command, password, focused bool) []layout.Command {
4949 if n == nil {
5050 return nil
5151 }
52- value := s.Value
5352 start, end := s.Range()
54- cursor := s.Cursor
55- if password {
56- boundaries := edit.Boundaries(value)
57- value = s.Mask()
58- render := edit.Boundaries(value)
59- for i, b := range boundaries {
60- if b == start {
61- start = render[i]
62- }
63- if b == end {
64- end = render[i]
65- }
66- if b == cursor {
67- cursor = render[i]
68- }
69- }
70- }
71- lines := textBoundaries(value, n, display)
53+ geometry := newEditorGeometry(s, n, display, password)
54+ lines := geometry.lines
7255 var result []layout.Command
7356 for i, line := range lines {
7457 h := 0.0
7558 if i < len(n.Lines) {
76- h = n.Lines[i].Height
59+ h = geometry.lineHeight(i)
7760 }
7861 for j := 0; j+1 < len(line); j += 2 {
7962 a, b := line[j], line[j+1]
@@ -94,29 +77,14 @@ func editorDecoration(s *edit.State, n *layout.Result, display []layout.Command,
9477 }
9578 }
9679 if focused {
97- var point *textBoundary
98- // The trailing edge of the preceding character takes priority over the
99- // leading edge of the following run when both map to the same offset.
100- for i := range lines {
101- for j := range lines[i] {
102- b := &lines[i][j]
103- if b.byteOffset == cursor && point == nil {
104- point = b
105- }
106- }
107- }
108- if point == nil && len(lines) == 0 {
80+ // Selection and caret share the geometry's caret policy: the trailing
81+ // edge of the preceding character wins over a later duplicate offset.
82+ point, found := geometry.caret(s.Cursor)
83+ if !found && len(lines) == 0 {
10984 result = append(result, layout.Command{Op: "caret", ID: n.ID, Rect: layout.Rect{X: n.Content.X, Y: n.Content.Y, W: 1, H: n.Content.H}, Color: caretColor, Opacity: 1})
11085 }
111- if point != nil {
112- h := n.Content.H
113- for i, line := range lines {
114- if len(line) > 0 && line[0].y == point.y && i < len(n.Lines) {
115- h = n.Lines[i].Height
116- break
117- }
118- }
119- result = append(result, layout.Command{Op: "caret", ID: n.ID, Rect: layout.Rect{X: point.x, Y: point.y, W: 1, H: h}, Color: caretColor, Opacity: 1})
86+ if found {
87+ result = append(result, layout.Command{Op: "caret", ID: n.ID, Rect: layout.Rect{X: point.x, Y: point.y, W: 1, H: geometry.lineHeight(point.line)}, Color: caretColor, Opacity: 1})
12088 }
12189 }
12290 return result
@@ -43,37 +43,20 @@ func (r *runtime) paintEditors(root *element, out *layout.Output) {
43 }43 }
44 44
45 // editorDecoration maps logical grapheme selection to visual cluster spans.45 // editorDecoration maps logical grapheme selection to visual cluster spans.
46-// A bidi boundary uses the run containing the character before the cursor:46+// The caret follows editorGeometry's policy: at an ambiguous boundary, such as
47-// later duplicate offsets must not override the first span's trailing edge.47+// a bidi run change, the preceding character's trailing edge is used.
48 func editorDecoration(s *edit.State, n *layout.Result, display []layout.Command, password, focused bool) []layout.Command {48 func editorDecoration(s *edit.State, n *layout.Result, display []layout.Command, password, focused bool) []layout.Command {
49 if n == nil {49 if n == nil {
50 return nil50 return nil
51 }51 }
52- value := s.Value
53 start, end := s.Range()52 start, end := s.Range()
54- cursor := s.Cursor53+ geometry := newEditorGeometry(s, n, display, password)
55- if password {54+ lines := geometry.lines
56- boundaries := edit.Boundaries(value)
57- value = s.Mask()
58- render := edit.Boundaries(value)
59- for i, b := range boundaries {
60- if b == start {
61- start = render[i]
62- }
63- if b == end {
64- end = render[i]
65- }
66- if b == cursor {
67- cursor = render[i]
68- }
69- }
70- }
71- lines := textBoundaries(value, n, display)
72 var result []layout.Command55 var result []layout.Command
73 for i, line := range lines {56 for i, line := range lines {
74 h := 0.057 h := 0.0
75 if i < len(n.Lines) {58 if i < len(n.Lines) {
76- h = n.Lines[i].Height59+ h = geometry.lineHeight(i)
77 }60 }
78 for j := 0; j+1 < len(line); j += 2 {61 for j := 0; j+1 < len(line); j += 2 {
79 a, b := line[j], line[j+1]62 a, b := line[j], line[j+1]
@@ -94,29 +77,14 @@ func editorDecoration(s *edit.State, n *layout.Result, display []layout.Command,
94 }77 }
95 }78 }
96 if focused {79 if focused {
97- var point *textBoundary80+ // Selection and caret share the geometry's caret policy: the trailing
98- // The trailing edge of the preceding character takes priority over the81+ // edge of the preceding character wins over a later duplicate offset.
99- // leading edge of the following run when both map to the same offset.82+ point, found := geometry.caret(s.Cursor)
100- for i := range lines {83+ if !found && len(lines) == 0 {
101- for j := range lines[i] {
102- b := &lines[i][j]
103- if b.byteOffset == cursor && point == nil {
104- point = b
105- }
106- }
107- }
108- if point == nil && len(lines) == 0 {
109 result = append(result, layout.Command{Op: "caret", ID: n.ID, Rect: layout.Rect{X: n.Content.X, Y: n.Content.Y, W: 1, H: n.Content.H}, Color: caretColor, Opacity: 1})84 result = append(result, layout.Command{Op: "caret", ID: n.ID, Rect: layout.Rect{X: n.Content.X, Y: n.Content.Y, W: 1, H: n.Content.H}, Color: caretColor, Opacity: 1})
110 }85 }
111- if point != nil {86+ if found {
112- h := n.Content.H87+ result = append(result, layout.Command{Op: "caret", ID: n.ID, Rect: layout.Rect{X: point.x, Y: point.y, W: 1, H: geometry.lineHeight(point.line)}, Color: caretColor, Opacity: 1})
113- for i, line := range lines {
114- if len(line) > 0 && line[0].y == point.y && i < len(n.Lines) {
115- h = n.Lines[i].Height
116- break
117- }
118- }
119- result = append(result, layout.Command{Op: "caret", ID: n.ID, Rect: layout.Rect{X: point.x, Y: point.y, W: 1, H: h}, Color: caretColor, Opacity: 1})
120 }88 }
121 }89 }
122 return result90 return result
M edit_preedit.go +7 -35
@@ -71,30 +71,11 @@ func preeditCommands(s *edit.State, n *layout.Result, e *element, engine *text.E
7171 if s.Preedit == "" || n == nil || e.computed == nil {
7272 return nil
7373 }
74- value := s.Value
75- cursor := s.Cursor
76- if e.password {
77- value = s.Mask()
78- i := 0
79- for k, b := range edit.Boundaries(s.Value) {
80- if b == cursor {
81- i = k
82- break
83- }
84- }
85- cursor = edit.Boundaries(value)[i]
86- }
87- var origin *textBoundary
88- lines := textBoundaries(value, n, display)
89- for i := range lines {
90- for j := range lines[i] {
91- if lines[i][j].byteOffset == cursor {
92- origin = &lines[i][j]
93- }
94- }
95- }
74+ // The composition starts at the same caret position that is painted.
75+ geometry := newEditorGeometry(s, n, display, e.password)
76+ origin, found := geometry.caret(s.Cursor)
9677 x, y := n.Content.X, n.Content.Y
97- if origin != nil {
78+ if found {
9879 x, y = origin.x, origin.y
9980 }
10081 st := e.computed.Style
@@ -128,18 +109,9 @@ func preeditCommands(s *edit.State, n *layout.Result, e *element, engine *text.E
128109 clamped = b
129110 }
130111 pos = clamped
131- if e.password {
132- // Preedit offsets are bytes in the original text; the display uses
133- // one bullet per original grapheme, including combined clusters.
134- index := 0
135- for _, b := range edit.Boundaries(s.Preedit) {
136- if b >= pos {
137- break
138- }
139- index++
140- }
141- pos = edit.Boundaries(preedit)[index]
142- }
112+ // Preedit offsets are bytes in the original text; a password shows one
113+ // bullet per original grapheme, including combined clusters.
114+ pos = newDisplayText(s.Preedit, e.password).toShown(pos)
143115 // The preedit cursor uses shaped cluster advances, not raw rune count.
144116 px := x
145117 pr := utf8.RuneCountInString(preedit[:pos])
@@ -71,30 +71,11 @@ func preeditCommands(s *edit.State, n *layout.Result, e *element, engine *text.E
71 if s.Preedit == "" || n == nil || e.computed == nil {71 if s.Preedit == "" || n == nil || e.computed == nil {
72 return nil72 return nil
73 }73 }
74- value := s.Value74+ // The composition starts at the same caret position that is painted.
75- cursor := s.Cursor75+ geometry := newEditorGeometry(s, n, display, e.password)
76- if e.password {76+ origin, found := geometry.caret(s.Cursor)
77- value = s.Mask()
78- i := 0
79- for k, b := range edit.Boundaries(s.Value) {
80- if b == cursor {
81- i = k
82- break
83- }
84- }
85- cursor = edit.Boundaries(value)[i]
86- }
87- var origin *textBoundary
88- lines := textBoundaries(value, n, display)
89- for i := range lines {
90- for j := range lines[i] {
91- if lines[i][j].byteOffset == cursor {
92- origin = &lines[i][j]
93- }
94- }
95- }
96 x, y := n.Content.X, n.Content.Y77 x, y := n.Content.X, n.Content.Y
97- if origin != nil {78+ if found {
98 x, y = origin.x, origin.y79 x, y = origin.x, origin.y
99 }80 }
100 st := e.computed.Style81 st := e.computed.Style
@@ -128,18 +109,9 @@ func preeditCommands(s *edit.State, n *layout.Result, e *element, engine *text.E
128 clamped = b109 clamped = b
129 }110 }
130 pos = clamped111 pos = clamped
131- if e.password {112+ // Preedit offsets are bytes in the original text; a password shows one
132- // Preedit offsets are bytes in the original text; the display uses113+ // bullet per original grapheme, including combined clusters.
133- // one bullet per original grapheme, including combined clusters.114+ pos = newDisplayText(s.Preedit, e.password).toShown(pos)
134- index := 0
135- for _, b := range edit.Boundaries(s.Preedit) {
136- if b >= pos {
137- break
138- }
139- index++
140- }
141- pos = edit.Boundaries(preedit)[index]
142- }
143 // The preedit cursor uses shaped cluster advances, not raw rune count.115 // The preedit cursor uses shaped cluster advances, not raw rune count.
144 px := x116 px := x
145 pr := utf8.RuneCountInString(preedit[:pos])117 pr := utf8.RuneCountInString(preedit[:pos])
M edit_scroll.go +4 -19
@@ -35,21 +35,9 @@ func (r *runtime) scrollCaret(root *element, out *layout.Output) bool {
3535 if s == nil || n == nil {
3636 return false
3737 }
38- rendered := e.text
39- cursor := s.Cursor
40- if e.password {
41- cursor = len([]rune(s.Value[:s.Cursor])) * len("•")
42- }
43- lines := textBoundaries(rendered, n, out.Display)
44- var caret *textBoundary
45- for i := range lines {
46- for j := range lines[i] {
47- if lines[i][j].byteOffset == cursor {
48- caret = &lines[i][j]
49- }
50- }
51- }
52- if caret == nil {
38+ geometry := newEditorGeometry(s, n, out.Display, e.password)
39+ caret, found := geometry.caret(s.Cursor)
40+ if !found {
5341 return false
5442 }
5543 old := r.state.scroll[id]
@@ -62,10 +50,7 @@ func (r *runtime) scrollCaret(root *element, out *layout.Output) bool {
6250 }
6351 next.H = 0
6452 } else {
65- height := 0.0
66- if len(n.Lines) > 0 {
67- height = n.Lines[0].Height
68- }
53+ height := geometry.lineHeight(caret.line)
6954 if caret.y < n.Content.Y {
7055 next.H += caret.y - n.Content.Y
7156 } else if caret.y+height > n.Content.Y+n.Content.H {
@@ -35,21 +35,9 @@ func (r *runtime) scrollCaret(root *element, out *layout.Output) bool {
35 if s == nil || n == nil {35 if s == nil || n == nil {
36 return false36 return false
37 }37 }
38- rendered := e.text38+ geometry := newEditorGeometry(s, n, out.Display, e.password)
39- cursor := s.Cursor39+ caret, found := geometry.caret(s.Cursor)
40- if e.password {40+ if !found {
41- cursor = len([]rune(s.Value[:s.Cursor])) * len("•")
42- }
43- lines := textBoundaries(rendered, n, out.Display)
44- var caret *textBoundary
45- for i := range lines {
46- for j := range lines[i] {
47- if lines[i][j].byteOffset == cursor {
48- caret = &lines[i][j]
49- }
50- }
51- }
52- if caret == nil {
53 return false41 return false
54 }42 }
55 old := r.state.scroll[id]43 old := r.state.scroll[id]
@@ -62,10 +50,7 @@ func (r *runtime) scrollCaret(root *element, out *layout.Output) bool {
62 }50 }
63 next.H = 051 next.H = 0
64 } else {52 } else {
65- height := 0.053+ height := geometry.lineHeight(caret.line)
66- if len(n.Lines) > 0 {
67- height = n.Lines[0].Height
68- }
69 if caret.y < n.Content.Y {54 if caret.y < n.Content.Y {
70 next.H += caret.y - n.Content.Y55 next.H += caret.y - n.Content.Y
71 } else if caret.y+height > n.Content.Y+n.Content.H {56 } else if caret.y+height > n.Content.Y+n.Content.H {
M internal/presentation/session/app.go +22 -45
@@ -40,9 +40,7 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
4040 }
4141 defer func() { s.waitCancel(); s.waiters.Wait() }()
4242 wlEvents := s.Window.StartReader()
43- dirty := true
44- var prepared render.Frame
45- havePrepared := false
43+ sched := newFrameSchedule()
4644 for !s.Window.Closed {
4745 if err := ctx.Err(); err != nil {
4846 return err
@@ -51,55 +49,40 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
5149 if err != nil {
5250 return err
5351 }
52+ // Resize or scale change (also set before RunApp, or by the event case
53+ // below): the prepared frame is stale until tick clears Window.Dirty.
5454 if s.Window.Dirty {
55- dirty = true
55+ sched.Invalidate()
5656 }
5757 if inputs := s.Window.DrainInput(nil); len(inputs) > 0 {
58- dirty = true
59- havePrepared = false
58+ sched.Invalidate()
6059 if app.Input != nil {
6160 if err := app.Input(inputs); err != nil {
6261 return err
6362 }
6463 }
6564 }
66- if dirty && s.Window.FrameReady {
67- if !havePrepared {
68- frame, changed, err := app.Draw()
69- if err != nil {
70- return err
71- }
72- if !changed {
73- dirty = false
74- } else {
75- prepared, havePrepared = frame, true
76- }
65+ committed, err := sched.Step(s.Window.FrameReady, app.Draw, func(prepared render.Frame) (bool, error) {
66+ instances, batches, stats := render.ListBatches(prepared)
67+ if len(stats.Skipped) != 0 {
68+ return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
7769 }
78- if havePrepared {
79- instances, batches, stats := render.ListBatches(prepared)
80- if len(stats.Skipped) != 0 {
81- s.Preparer.Atlas.MarkAllDirty()
82- return fmt.Errorf("unsupported list operations: %v", stats.Skipped)
83- }
84- ok, err := s.tick(nil, instances, prepared.Uploads, batches)
85- if err != nil {
70+ return s.tick(nil, instances, prepared.Uploads, batches)
71+ })
72+ if err != nil {
73+ return err
74+ }
75+ if committed {
76+ if app.Committed != nil {
77+ if err := app.Committed(s.frame); err != nil {
8678 return err
8779 }
88- if ok {
89- dirty = false
90- havePrepared = false
91- if app.Committed != nil {
92- if err := app.Committed(s.frame); err != nil {
93- return err
94- }
95- }
96- continue
97- }
9880 }
81+ continue
9982 }
10083 var retry, repeat <-chan time.Time
101- if pending || s.framePending || (dirty && havePrepared && s.Window.FrameReady) {
102- retry = time.After(5 * time.Millisecond)
84+ if sched.NeedsRetry(pending || s.framePending, s.Window.FrameReady) {
85+ retry = time.After(retryInterval)
10386 }
10487 if adapter := s.Window.InputAdapter(); adapter != nil {
10588 if at, ok := adapter.NextRepeat(); ok {
@@ -110,14 +93,12 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
11093 case <-ctx.Done():
11194 return ctx.Err()
11295 case <-app.Wake:
113- dirty = true
114- havePrepared = false
96+ sched.Invalidate()
11597 case <-retry:
11698 case <-repeat:
11799 if adapter := s.Window.InputAdapter(); adapter != nil {
118100 if input, ok := adapter.Tick(time.Now()); ok {
119- dirty = true
120- havePrepared = false
101+ sched.Invalidate()
121102 if app.Input != nil {
122103 if err := app.Input([]wayland.Input{input}); err != nil {
123104 return err
@@ -141,10 +122,6 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
141122 return err
142123 }
143124 }
144- if s.Window.Dirty {
145- dirty = true
146- havePrepared = false
147- }
148125 // DrainInput is handled at the start of the next iteration.
149126 case ev := <-s.releases:
150127 if err := s.release(ev); err != nil {
@@ -40,9 +40,7 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
40 }40 }
41 defer func() { s.waitCancel(); s.waiters.Wait() }()41 defer func() { s.waitCancel(); s.waiters.Wait() }()
42 wlEvents := s.Window.StartReader()42 wlEvents := s.Window.StartReader()
43- dirty := true43+ sched := newFrameSchedule()
44- var prepared render.Frame
45- havePrepared := false
46 for !s.Window.Closed {44 for !s.Window.Closed {
47 if err := ctx.Err(); err != nil {45 if err := ctx.Err(); err != nil {
48 return err46 return err
@@ -51,55 +49,40 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
51 if err != nil {49 if err != nil {
52 return err50 return err
53 }51 }
52+ // Resize or scale change (also set before RunApp, or by the event case
53+ // below): the prepared frame is stale until tick clears Window.Dirty.
54 if s.Window.Dirty {54 if s.Window.Dirty {
55- dirty = true55+ sched.Invalidate()
56 }56 }
57 if inputs := s.Window.DrainInput(nil); len(inputs) > 0 {57 if inputs := s.Window.DrainInput(nil); len(inputs) > 0 {
58- dirty = true58+ sched.Invalidate()
59- havePrepared = false
60 if app.Input != nil {59 if app.Input != nil {
61 if err := app.Input(inputs); err != nil {60 if err := app.Input(inputs); err != nil {
62 return err61 return err
63 }62 }
64 }63 }
65 }64 }
66- if dirty && s.Window.FrameReady {65+ committed, err := sched.Step(s.Window.FrameReady, app.Draw, func(prepared render.Frame) (bool, error) {
67- if !havePrepared {66+ instances, batches, stats := render.ListBatches(prepared)
68- frame, changed, err := app.Draw()67+ if len(stats.Skipped) != 0 {
69- if err != nil {68+ return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
70- return err
71- }
72- if !changed {
73- dirty = false
74- } else {
75- prepared, havePrepared = frame, true
76- }
77 }69 }
78- if havePrepared {70+ return s.tick(nil, instances, prepared.Uploads, batches)
79- instances, batches, stats := render.ListBatches(prepared)71+ })
80- if len(stats.Skipped) != 0 {72+ if err != nil {
81- s.Preparer.Atlas.MarkAllDirty()73+ return err
82- return fmt.Errorf("unsupported list operations: %v", stats.Skipped)74+ }
83- }75+ if committed {
84- ok, err := s.tick(nil, instances, prepared.Uploads, batches)76+ if app.Committed != nil {
85- if err != nil {77+ if err := app.Committed(s.frame); err != nil {
86 return err78 return err
87 }79 }
88- if ok {
89- dirty = false
90- havePrepared = false
91- if app.Committed != nil {
92- if err := app.Committed(s.frame); err != nil {
93- return err
94- }
95- }
96- continue
97- }
98 }80 }
81+ continue
99 }82 }
100 var retry, repeat <-chan time.Time83 var retry, repeat <-chan time.Time
101- if pending || s.framePending || (dirty && havePrepared && s.Window.FrameReady) {84+ if sched.NeedsRetry(pending || s.framePending, s.Window.FrameReady) {
102- retry = time.After(5 * time.Millisecond)85+ retry = time.After(retryInterval)
103 }86 }
104 if adapter := s.Window.InputAdapter(); adapter != nil {87 if adapter := s.Window.InputAdapter(); adapter != nil {
105 if at, ok := adapter.NextRepeat(); ok {88 if at, ok := adapter.NextRepeat(); ok {
@@ -110,14 +93,12 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
110 case <-ctx.Done():93 case <-ctx.Done():
111 return ctx.Err()94 return ctx.Err()
112 case <-app.Wake:95 case <-app.Wake:
113- dirty = true96+ sched.Invalidate()
114- havePrepared = false
115 case <-retry:97 case <-retry:
116 case <-repeat:98 case <-repeat:
117 if adapter := s.Window.InputAdapter(); adapter != nil {99 if adapter := s.Window.InputAdapter(); adapter != nil {
118 if input, ok := adapter.Tick(time.Now()); ok {100 if input, ok := adapter.Tick(time.Now()); ok {
119- dirty = true101+ sched.Invalidate()
120- havePrepared = false
121 if app.Input != nil {102 if app.Input != nil {
122 if err := app.Input([]wayland.Input{input}); err != nil {103 if err := app.Input([]wayland.Input{input}); err != nil {
123 return err104 return err
@@ -141,10 +122,6 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
141 return err122 return err
142 }123 }
143 }124 }
144- if s.Window.Dirty {
145- dirty = true
146- havePrepared = false
147- }
148 // DrainInput is handled at the start of the next iteration.125 // DrainInput is handled at the start of the next iteration.
149 case ev := <-s.releases:126 case ev := <-s.releases:
150 if err := s.release(ev); err != nil {127 if err := s.release(ev); err != nil {
A internal/presentation/session/schedule.go +75 -0
@@ -0,0 +1,75 @@
1+//go:build linux
2+
3+package session
4+
5+import (
6+ "time"
7+
8+ "github.com/bnema/nefergui/internal/render"
9+)
10+
11+// retryInterval bounds how long the owner loop sleeps while a frame is blocked
12+// on GPU or buffer readiness that has no event source of its own.
13+const retryInterval = 5 * time.Millisecond
14+
15+// frameSchedule is RunApp's frame state, touched only from the owner loop.
16+// A frame is wanted (dirty) until the compositor accepted a commit or the app
17+// reported no change. A prepared display list is kept across blocked submits so
18+// a retry does not call Draw again, and it is dropped whenever input, a wake, a
19+// resize or a scale change makes it stale.
20+type frameSchedule struct {
21+ dirty bool
22+ prepared render.Frame
23+ have bool
24+}
25+
26+// newFrameSchedule starts dirty so the first configured frame is drawn.
27+func newFrameSchedule() *frameSchedule { return &frameSchedule{dirty: true} }
28+
29+// Invalidate records that the UI or surface changed: draw again, and never
30+// submit a previously prepared frame.
31+func (f *frameSchedule) Invalidate() {
32+ f.dirty = true
33+ f.have = false
34+ f.prepared = render.Frame{}
35+}
36+
37+// Step attempts one frame. Nothing is drawn unless a frame is wanted and the
38+// compositor allows one (ready). Draw runs only when no prepared frame is held;
39+// an unchanged draw clears dirty. submit reports whether the frame was
40+// committed: on false the prepared frame is kept for the next attempt, on true
41+// the schedule is clean until the next Invalidate.
42+func (f *frameSchedule) Step(
43+ ready bool,
44+ draw func() (render.Frame, bool, error),
45+ submit func(render.Frame) (bool, error),
46+) (committed bool, err error) {
47+ if !f.dirty || !ready {
48+ return false, nil
49+ }
50+ if !f.have {
51+ frame, changed, err := draw()
52+ if err != nil {
53+ return false, err
54+ }
55+ if !changed {
56+ f.dirty = false
57+ return false, nil
58+ }
59+ f.prepared, f.have = frame, true
60+ }
61+ committed, err = submit(f.prepared)
62+ if err != nil || !committed {
63+ return false, err
64+ }
65+ f.dirty, f.have, f.prepared = false, false, render.Frame{}
66+ return true, nil
67+}
68+
69+// NeedsRetry reports whether the loop must wake itself after retryInterval.
70+// gpuPending covers work whose completion only polling can observe. A frame
71+// still to be drawn, or one waiting for a frame callback, is woken by events
72+// instead, so an idle or compositor-throttled loop never spins.
73+func (f *frameSchedule) NeedsRetry(gpuPending, ready bool) bool {
74+ return gpuPending || (f.dirty && f.have && ready)
75+}
@@ -0,0 +1,75 @@
1+//go:build linux
2+
3+package session
4+
5+import (
6+ "time"
7+
8+ "github.com/bnema/nefergui/internal/render"
9+)
10+
11+// retryInterval bounds how long the owner loop sleeps while a frame is blocked
12+// on GPU or buffer readiness that has no event source of its own.
13+const retryInterval = 5 * time.Millisecond
14+
15+// frameSchedule is RunApp's frame state, touched only from the owner loop.
16+// A frame is wanted (dirty) until the compositor accepted a commit or the app
17+// reported no change. A prepared display list is kept across blocked submits so
18+// a retry does not call Draw again, and it is dropped whenever input, a wake, a
19+// resize or a scale change makes it stale.
20+type frameSchedule struct {
21+ dirty bool
22+ prepared render.Frame
23+ have bool
24+}
25+
26+// newFrameSchedule starts dirty so the first configured frame is drawn.
27+func newFrameSchedule() *frameSchedule { return &frameSchedule{dirty: true} }
28+
29+// Invalidate records that the UI or surface changed: draw again, and never
30+// submit a previously prepared frame.
31+func (f *frameSchedule) Invalidate() {
32+ f.dirty = true
33+ f.have = false
34+ f.prepared = render.Frame{}
35+}
36+
37+// Step attempts one frame. Nothing is drawn unless a frame is wanted and the
38+// compositor allows one (ready). Draw runs only when no prepared frame is held;
39+// an unchanged draw clears dirty. submit reports whether the frame was
40+// committed: on false the prepared frame is kept for the next attempt, on true
41+// the schedule is clean until the next Invalidate.
42+func (f *frameSchedule) Step(
43+ ready bool,
44+ draw func() (render.Frame, bool, error),
45+ submit func(render.Frame) (bool, error),
46+) (committed bool, err error) {
47+ if !f.dirty || !ready {
48+ return false, nil
49+ }
50+ if !f.have {
51+ frame, changed, err := draw()
52+ if err != nil {
53+ return false, err
54+ }
55+ if !changed {
56+ f.dirty = false
57+ return false, nil
58+ }
59+ f.prepared, f.have = frame, true
60+ }
61+ committed, err = submit(f.prepared)
62+ if err != nil || !committed {
63+ return false, err
64+ }
65+ f.dirty, f.have, f.prepared = false, false, render.Frame{}
66+ return true, nil
67+}
68+
69+// NeedsRetry reports whether the loop must wake itself after retryInterval.
70+// gpuPending covers work whose completion only polling can observe. A frame
71+// still to be drawn, or one waiting for a frame callback, is woken by events
72+// instead, so an idle or compositor-throttled loop never spins.
73+func (f *frameSchedule) NeedsRetry(gpuPending, ready bool) bool {
74+ return gpuPending || (f.dirty && f.have && ready)
75+}
A internal/presentation/session/schedule_test.go +147 -0
@@ -0,0 +1,147 @@
1+//go:build linux
2+
3+package session
4+
5+import (
6+ "errors"
7+ "testing"
8+
9+ "github.com/bnema/nefergui/internal/platform/wayland"
10+ "github.com/bnema/nefergui/internal/render"
11+)
12+
13+// scheduleHarness plays the app and session sides of frameSchedule.
14+type scheduleHarness struct {
15+ t *testing.T
16+ s *frameSchedule
17+ draws int
18+ submits []int // frame id (Quads length) of each submit attempt
19+ changed bool
20+ blocked bool
21+ ready bool
22+ drawErr error
23+ submitErr error
24+}
25+
26+func newHarness(t *testing.T) *scheduleHarness {
27+ return &scheduleHarness{t: t, s: newFrameSchedule(), changed: true, ready: true}
28+}
29+
30+func (h *scheduleHarness) step() bool {
31+ h.t.Helper()
32+ ok, err := h.s.Step(h.ready, func() (render.Frame, bool, error) {
33+ h.draws++
34+ return render.Frame{Quads: make([]render.Quad, h.draws)}, h.changed, h.drawErr
35+ }, func(f render.Frame) (bool, error) {
36+ h.submits = append(h.submits, len(f.Quads))
37+ return !h.blocked, h.submitErr
38+ })
39+ if err != nil && h.drawErr == nil && h.submitErr == nil {
40+ h.t.Fatal(err)
41+ }
42+ return ok
43+}
44+
45+func TestScheduleBlockedFrameKeepsPreparedAndRetries(t *testing.T) {
46+ h := newHarness(t)
47+ h.blocked = true
48+ if h.step() || h.draws != 1 || !h.s.NeedsRetry(false, true) {
49+ t.Fatalf("blocked: draws=%d retry=%v", h.draws, h.s.NeedsRetry(false, true))
50+ }
51+ // Retries resubmit the same prepared frame without redrawing.
52+ if h.step() || h.step() || h.draws != 1 || len(h.submits) != 3 || h.submits[2] != 1 {
53+ t.Fatalf("retry redrew: draws=%d submits=%v", h.draws, h.submits)
54+ }
55+ h.blocked = false
56+ if !h.step() || h.draws != 1 || h.s.NeedsRetry(false, true) {
57+ t.Fatalf("release did not commit cleanly: draws=%d submits=%v", h.draws, h.submits)
58+ }
59+ if h.step() || len(h.submits) != 4 {
60+ t.Fatalf("clean schedule submitted again: %v", h.submits)
61+ }
62+}
63+
64+func TestScheduleInvalidateDropsPreparedFrame(t *testing.T) {
65+ for _, name := range []string{"wake or input", "resize"} {
66+ t.Run(name, func(t *testing.T) {
67+ h := newHarness(t)
68+ h.blocked = true
69+ h.step()
70+ if name == "resize" {
71+ // RunApp invalidates at the top of each iteration while Window.Dirty is set.
72+ w := &wayland.Window{}
73+ if err := w.Apply(wayland.Event{Kind: wayland.ConfigureSize, Width: 640, Height: 480}); err != nil || !w.Dirty {
74+ t.Fatalf("configure did not dirty window: %v", err)
75+ }
76+ }
77+ h.s.Invalidate()
78+ h.blocked = false
79+ if !h.step() || h.draws != 2 || h.submits[len(h.submits)-1] != 2 {
80+ t.Fatalf("stale frame submitted: draws=%d submits=%v", h.draws, h.submits)
81+ }
82+ })
83+ }
84+}
85+
86+func TestScheduleDrawUnchangedGoesIdle(t *testing.T) {
87+ h := newHarness(t)
88+ h.changed = false
89+ if h.step() || h.draws != 1 || len(h.submits) != 0 {
90+ t.Fatalf("unchanged draw: draws=%d submits=%v", h.draws, h.submits)
91+ }
92+ if h.s.NeedsRetry(false, true) {
93+ t.Fatal("idle schedule asks for retry (busy loop)")
94+ }
95+ for i := 0; i < 3; i++ {
96+ h.step()
97+ }
98+ if h.draws != 1 {
99+ t.Fatalf("idle loop redrew %d times", h.draws)
100+ }
101+ h.s.Invalidate()
102+ h.changed = true
103+ if !h.step() || h.draws != 2 {
104+ t.Fatalf("wake after idle: draws=%d", h.draws)
105+ }
106+}
107+
108+func TestScheduleWaitsForFrameCallback(t *testing.T) {
109+ h := newHarness(t)
110+ h.ready = false
111+ if h.step() || h.draws != 0 || len(h.submits) != 0 || h.s.NeedsRetry(false, false) {
112+ t.Fatalf("drew before frame callback: draws=%d", h.draws)
113+ }
114+ h.ready = true
115+ if !h.step() || h.draws != 1 {
116+ t.Fatalf("frame callback did not release draw: draws=%d", h.draws)
117+ }
118+ // A prepared frame waiting for a callback is woken by the event, not polled.
119+ h.s.Invalidate()
120+ h.blocked = true
121+ h.step()
122+ if h.s.NeedsRetry(false, false) || !h.s.NeedsRetry(false, true) {
123+ t.Fatal("retry must follow frame readiness")
124+ }
125+}
126+
127+func TestSchedulePendingGPUForcesRetry(t *testing.T) {
128+ h := newHarness(t)
129+ h.changed = false
130+ h.step()
131+ if !h.s.NeedsRetry(true, false) {
132+ t.Fatal("pending GPU work must schedule a bounded retry")
133+ }
134+}
135+
136+func TestScheduleErrorsPropagate(t *testing.T) {
137+ h := newHarness(t)
138+ h.drawErr = errors.New("draw")
139+ if _, err := h.s.Step(true, func() (render.Frame, bool, error) { return render.Frame{}, false, h.drawErr }, nil); err != h.drawErr {
140+ t.Fatalf("draw err = %v", err)
141+ }
142+ h.drawErr = nil
143+ h.submitErr = errors.New("submit")
144+ if ok, err := h.s.Step(true, func() (render.Frame, bool, error) { return render.Frame{}, true, nil }, func(render.Frame) (bool, error) { return true, h.submitErr }); ok || err != h.submitErr {
145+ t.Fatalf("submit error: ok=%v err=%v", ok, err)
146+ }
147+}
@@ -0,0 +1,147 @@
1+//go:build linux
2+
3+package session
4+
5+import (
6+ "errors"
7+ "testing"
8+
9+ "github.com/bnema/nefergui/internal/platform/wayland"
10+ "github.com/bnema/nefergui/internal/render"
11+)
12+
13+// scheduleHarness plays the app and session sides of frameSchedule.
14+type scheduleHarness struct {
15+ t *testing.T
16+ s *frameSchedule
17+ draws int
18+ submits []int // frame id (Quads length) of each submit attempt
19+ changed bool
20+ blocked bool
21+ ready bool
22+ drawErr error
23+ submitErr error
24+}
25+
26+func newHarness(t *testing.T) *scheduleHarness {
27+ return &scheduleHarness{t: t, s: newFrameSchedule(), changed: true, ready: true}
28+}
29+
30+func (h *scheduleHarness) step() bool {
31+ h.t.Helper()
32+ ok, err := h.s.Step(h.ready, func() (render.Frame, bool, error) {
33+ h.draws++
34+ return render.Frame{Quads: make([]render.Quad, h.draws)}, h.changed, h.drawErr
35+ }, func(f render.Frame) (bool, error) {
36+ h.submits = append(h.submits, len(f.Quads))
37+ return !h.blocked, h.submitErr
38+ })
39+ if err != nil && h.drawErr == nil && h.submitErr == nil {
40+ h.t.Fatal(err)
41+ }
42+ return ok
43+}
44+
45+func TestScheduleBlockedFrameKeepsPreparedAndRetries(t *testing.T) {
46+ h := newHarness(t)
47+ h.blocked = true
48+ if h.step() || h.draws != 1 || !h.s.NeedsRetry(false, true) {
49+ t.Fatalf("blocked: draws=%d retry=%v", h.draws, h.s.NeedsRetry(false, true))
50+ }
51+ // Retries resubmit the same prepared frame without redrawing.
52+ if h.step() || h.step() || h.draws != 1 || len(h.submits) != 3 || h.submits[2] != 1 {
53+ t.Fatalf("retry redrew: draws=%d submits=%v", h.draws, h.submits)
54+ }
55+ h.blocked = false
56+ if !h.step() || h.draws != 1 || h.s.NeedsRetry(false, true) {
57+ t.Fatalf("release did not commit cleanly: draws=%d submits=%v", h.draws, h.submits)
58+ }
59+ if h.step() || len(h.submits) != 4 {
60+ t.Fatalf("clean schedule submitted again: %v", h.submits)
61+ }
62+}
63+
64+func TestScheduleInvalidateDropsPreparedFrame(t *testing.T) {
65+ for _, name := range []string{"wake or input", "resize"} {
66+ t.Run(name, func(t *testing.T) {
67+ h := newHarness(t)
68+ h.blocked = true
69+ h.step()
70+ if name == "resize" {
71+ // RunApp invalidates at the top of each iteration while Window.Dirty is set.
72+ w := &wayland.Window{}
73+ if err := w.Apply(wayland.Event{Kind: wayland.ConfigureSize, Width: 640, Height: 480}); err != nil || !w.Dirty {
74+ t.Fatalf("configure did not dirty window: %v", err)
75+ }
76+ }
77+ h.s.Invalidate()
78+ h.blocked = false
79+ if !h.step() || h.draws != 2 || h.submits[len(h.submits)-1] != 2 {
80+ t.Fatalf("stale frame submitted: draws=%d submits=%v", h.draws, h.submits)
81+ }
82+ })
83+ }
84+}
85+
86+func TestScheduleDrawUnchangedGoesIdle(t *testing.T) {
87+ h := newHarness(t)
88+ h.changed = false
89+ if h.step() || h.draws != 1 || len(h.submits) != 0 {
90+ t.Fatalf("unchanged draw: draws=%d submits=%v", h.draws, h.submits)
91+ }
92+ if h.s.NeedsRetry(false, true) {
93+ t.Fatal("idle schedule asks for retry (busy loop)")
94+ }
95+ for i := 0; i < 3; i++ {
96+ h.step()
97+ }
98+ if h.draws != 1 {
99+ t.Fatalf("idle loop redrew %d times", h.draws)
100+ }
101+ h.s.Invalidate()
102+ h.changed = true
103+ if !h.step() || h.draws != 2 {
104+ t.Fatalf("wake after idle: draws=%d", h.draws)
105+ }
106+}
107+
108+func TestScheduleWaitsForFrameCallback(t *testing.T) {
109+ h := newHarness(t)
110+ h.ready = false
111+ if h.step() || h.draws != 0 || len(h.submits) != 0 || h.s.NeedsRetry(false, false) {
112+ t.Fatalf("drew before frame callback: draws=%d", h.draws)
113+ }
114+ h.ready = true
115+ if !h.step() || h.draws != 1 {
116+ t.Fatalf("frame callback did not release draw: draws=%d", h.draws)
117+ }
118+ // A prepared frame waiting for a callback is woken by the event, not polled.
119+ h.s.Invalidate()
120+ h.blocked = true
121+ h.step()
122+ if h.s.NeedsRetry(false, false) || !h.s.NeedsRetry(false, true) {
123+ t.Fatal("retry must follow frame readiness")
124+ }
125+}
126+
127+func TestSchedulePendingGPUForcesRetry(t *testing.T) {
128+ h := newHarness(t)
129+ h.changed = false
130+ h.step()
131+ if !h.s.NeedsRetry(true, false) {
132+ t.Fatal("pending GPU work must schedule a bounded retry")
133+ }
134+}
135+
136+func TestScheduleErrorsPropagate(t *testing.T) {
137+ h := newHarness(t)
138+ h.drawErr = errors.New("draw")
139+ if _, err := h.s.Step(true, func() (render.Frame, bool, error) { return render.Frame{}, false, h.drawErr }, nil); err != h.drawErr {
140+ t.Fatalf("draw err = %v", err)
141+ }
142+ h.drawErr = nil
143+ h.submitErr = errors.New("submit")
144+ if ok, err := h.s.Step(true, func() (render.Frame, bool, error) { return render.Frame{}, true, nil }, func(render.Frame) (bool, error) { return true, h.submitErr }); ok || err != h.submitErr {
145+ t.Fatalf("submit error: ok=%v err=%v", ok, err)
146+ }
147+}
M internal/presentation/session/session.go +4 -11
@@ -343,13 +343,6 @@ func (s *Session) TickList(instances []vkdevice.Instance, uploads []text.Upload)
343343 }
344344
345345 func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) {
346- // Uploads were drained by Prepare. Restore them if this tick did not submit.
347- submitted := false
348- defer func() {
349- if !submitted && len(uploads) > 0 {
350- s.Preparer.Atlas.MarkAllDirty()
351- }
352- }()
353346 started := time.Now()
354347 if s.Window.Dirty {
355348 w, h, err := s.Window.PhysicalSize()
@@ -402,7 +395,8 @@ func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploa
402395 if err = slot.Frame.Submit(slot.Wait); err != nil {
403396 return false, err
404397 }
405- submitted = true
398+ // The render module keeps uploads owed until this acknowledgement.
399+ s.Preparer.Submitted(uploads)
406400 if slot.Wait != nil {
407401 slot.WaitInFlight = true
408402 }
@@ -677,8 +671,8 @@ func (s *Session) RunWithCommit(ctx context.Context, frames int, draw func(int)
677671 return s.runWithCommit(ctx, frames, func(i int) (bool, error) { return s.Tick(draw(i)) }, committed)
678672 }
679673
680-// RunPreparedListWithCommit prepares uploads on every attempt. A skipped
681-// submission restores dirty pages, so the next attempt resends them.
674+// RunPreparedListWithCommit prepares uploads on every attempt. Preparer keeps
675+// them owed until a submission, so a skipped attempt is resent by the next one.
682676 func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, draw func(int) (render.Frame, error), committed func(int) error) (int, error) {
683677 if draw == nil {
684678 return 0, fmt.Errorf("nil draw function")
@@ -690,7 +684,6 @@ func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, dra
690684 }
691685 instances, batches, stats := render.ListBatches(frame)
692686 if len(stats.Skipped) != 0 {
693- s.Preparer.Atlas.MarkAllDirty()
694687 return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
695688 }
696689 return s.tick(nil, instances, frame.Uploads, batches)
@@ -343,13 +343,6 @@ func (s *Session) TickList(instances []vkdevice.Instance, uploads []text.Upload)
343 }343 }
344 344
345 func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) {345 func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) {
346- // Uploads were drained by Prepare. Restore them if this tick did not submit.
347- submitted := false
348- defer func() {
349- if !submitted && len(uploads) > 0 {
350- s.Preparer.Atlas.MarkAllDirty()
351- }
352- }()
353 started := time.Now()346 started := time.Now()
354 if s.Window.Dirty {347 if s.Window.Dirty {
355 w, h, err := s.Window.PhysicalSize()348 w, h, err := s.Window.PhysicalSize()
@@ -402,7 +395,8 @@ func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploa
402 if err = slot.Frame.Submit(slot.Wait); err != nil {395 if err = slot.Frame.Submit(slot.Wait); err != nil {
403 return false, err396 return false, err
404 }397 }
405- submitted = true398+ // The render module keeps uploads owed until this acknowledgement.
399+ s.Preparer.Submitted(uploads)
406 if slot.Wait != nil {400 if slot.Wait != nil {
407 slot.WaitInFlight = true401 slot.WaitInFlight = true
408 }402 }
@@ -677,8 +671,8 @@ func (s *Session) RunWithCommit(ctx context.Context, frames int, draw func(int)
677 return s.runWithCommit(ctx, frames, func(i int) (bool, error) { return s.Tick(draw(i)) }, committed)671 return s.runWithCommit(ctx, frames, func(i int) (bool, error) { return s.Tick(draw(i)) }, committed)
678 }672 }
679 673
680-// RunPreparedListWithCommit prepares uploads on every attempt. A skipped674+// RunPreparedListWithCommit prepares uploads on every attempt. Preparer keeps
681-// submission restores dirty pages, so the next attempt resends them.675+// them owed until a submission, so a skipped attempt is resent by the next one.
682 func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, draw func(int) (render.Frame, error), committed func(int) error) (int, error) {676 func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, draw func(int) (render.Frame, error), committed func(int) error) (int, error) {
683 if draw == nil {677 if draw == nil {
684 return 0, fmt.Errorf("nil draw function")678 return 0, fmt.Errorf("nil draw function")
@@ -690,7 +684,6 @@ func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, dra
690 }684 }
691 instances, batches, stats := render.ListBatches(frame)685 instances, batches, stats := render.ListBatches(frame)
692 if len(stats.Skipped) != 0 {686 if len(stats.Skipped) != 0 {
693- s.Preparer.Atlas.MarkAllDirty()
694 return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)687 return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
695 }688 }
696 return s.tick(nil, instances, frame.Uploads, batches)689 return s.tick(nil, instances, frame.Uploads, batches)
M internal/render/benchmark_test.go +4 -1
@@ -39,9 +39,11 @@ func BenchmarkPrepare(b *testing.B) {
3939 if err != nil {
4040 b.Fatal(err)
4141 }
42- if _, err = p.Prepare(commands, 1, 960, 640); err != nil {
42+ warm, err := p.Prepare(commands, 1, 960, 640)
43+ if err != nil {
4344 b.Fatal(err)
4445 }
46+ p.Submitted(warm.Uploads)
4547 b.ReportAllocs()
4648 b.ResetTimer()
4749 for i := 0; i < b.N; i++ {
@@ -49,5 +51,6 @@ func BenchmarkPrepare(b *testing.B) {
4951 if err != nil || len(frame.Quads) == 0 {
5052 b.Fatalf("prepare: %v", err)
5153 }
54+ p.Submitted(frame.Uploads)
5255 }
5356 }
@@ -39,9 +39,11 @@ func BenchmarkPrepare(b *testing.B) {
39 if err != nil {39 if err != nil {
40 b.Fatal(err)40 b.Fatal(err)
41 }41 }
42- if _, err = p.Prepare(commands, 1, 960, 640); err != nil {42+ warm, err := p.Prepare(commands, 1, 960, 640)
43+ if err != nil {
43 b.Fatal(err)44 b.Fatal(err)
44 }45 }
46+ p.Submitted(warm.Uploads)
45 b.ReportAllocs()47 b.ReportAllocs()
46 b.ResetTimer()48 b.ResetTimer()
47 for i := 0; i < b.N; i++ {49 for i := 0; i < b.N; i++ {
@@ -49,5 +51,6 @@ func BenchmarkPrepare(b *testing.B) {
49 if err != nil || len(frame.Quads) == 0 {51 if err != nil || len(frame.Quads) == 0 {
50 b.Fatalf("prepare: %v", err)52 b.Fatalf("prepare: %v", err)
51 }53 }
54+ p.Submitted(frame.Uploads)
52 }55 }
53 }56 }
M internal/render/prepare.go +28 -1
@@ -38,7 +38,17 @@ type Frame struct {
3838 Uploads []text.Upload
3939 }
4040
41-type Preparer struct{ Atlas *text.Atlas }
41+// Preparer owns the glyph atlas and the obligation to deliver its changes to the
42+// GPU mirror. Prepare hands the caller every change not yet known to be
43+// submitted; Submitted is the only acknowledgement. A frame that is retried,
44+// abandoned, superseded or never reached submission therefore never loses
45+// atlas changes: the next Prepare resends whole pages (placements are kept).
46+type Preparer struct {
47+ Atlas *text.Atlas
48+ // owed is true while the latest issued uploads have not been submitted.
49+ owed bool
50+ issued []text.Upload
51+}
4252
4353 // NewPreparer bounds the grayscale glyph atlas. Call Prepare synchronously with
4454 // layout; the atlas and shaped faces are not safe for concurrent use.
@@ -172,10 +182,27 @@ func (p *Preparer) Prepare(commands []layout.Command, scale float64, width, heig
172182 if len(stack) > 0 {
173183 return Frame{}, fmt.Errorf("render: unbalanced clip-push")
174184 }
185+ if p.owed {
186+ // The previous frame's uploads may never have reached the GPU. Full pages
187+ // subsume them and any changes made since.
188+ p.Atlas.MarkAllDirty()
189+ }
175190 frame.Uploads = p.Atlas.Uploads()
191+ p.issued, p.owed = frame.Uploads, len(frame.Uploads) > 0
176192 return frame, nil
177193 }
178194
195+// Submitted acknowledges that uploads, as returned in the most recent Prepare's
196+// Frame.Uploads, were recorded in a successfully submitted GPU submission.
197+// Any other slice (an older, superseded frame or foreign data) is ignored and
198+// the latest frame's uploads stay owed. Call it on the Prepare owner's loop.
199+func (p *Preparer) Submitted(uploads []text.Upload) {
200+ if p == nil || len(uploads) == 0 || len(uploads) != len(p.issued) || &uploads[0] != &p.issued[0] {
201+ return
202+ }
203+ p.owed, p.issued = false, nil
204+}
205+
179206 func opacity(c css.Color, a float64) css.Color {
180207 c.A *= math.Max(0, math.Min(1, a))
181208 return c
@@ -38,7 +38,17 @@ type Frame struct {
38 Uploads []text.Upload38 Uploads []text.Upload
39 }39 }
40 40
41-type Preparer struct{ Atlas *text.Atlas }41+// Preparer owns the glyph atlas and the obligation to deliver its changes to the
42+// GPU mirror. Prepare hands the caller every change not yet known to be
43+// submitted; Submitted is the only acknowledgement. A frame that is retried,
44+// abandoned, superseded or never reached submission therefore never loses
45+// atlas changes: the next Prepare resends whole pages (placements are kept).
46+type Preparer struct {
47+ Atlas *text.Atlas
48+ // owed is true while the latest issued uploads have not been submitted.
49+ owed bool
50+ issued []text.Upload
51+}
42 52
43 // NewPreparer bounds the grayscale glyph atlas. Call Prepare synchronously with53 // NewPreparer bounds the grayscale glyph atlas. Call Prepare synchronously with
44 // layout; the atlas and shaped faces are not safe for concurrent use.54 // layout; the atlas and shaped faces are not safe for concurrent use.
@@ -172,10 +182,27 @@ func (p *Preparer) Prepare(commands []layout.Command, scale float64, width, heig
172 if len(stack) > 0 {182 if len(stack) > 0 {
173 return Frame{}, fmt.Errorf("render: unbalanced clip-push")183 return Frame{}, fmt.Errorf("render: unbalanced clip-push")
174 }184 }
185+ if p.owed {
186+ // The previous frame's uploads may never have reached the GPU. Full pages
187+ // subsume them and any changes made since.
188+ p.Atlas.MarkAllDirty()
189+ }
175 frame.Uploads = p.Atlas.Uploads()190 frame.Uploads = p.Atlas.Uploads()
191+ p.issued, p.owed = frame.Uploads, len(frame.Uploads) > 0
176 return frame, nil192 return frame, nil
177 }193 }
178 194
195+// Submitted acknowledges that uploads, as returned in the most recent Prepare's
196+// Frame.Uploads, were recorded in a successfully submitted GPU submission.
197+// Any other slice (an older, superseded frame or foreign data) is ignored and
198+// the latest frame's uploads stay owed. Call it on the Prepare owner's loop.
199+func (p *Preparer) Submitted(uploads []text.Upload) {
200+ if p == nil || len(uploads) == 0 || len(uploads) != len(p.issued) || &uploads[0] != &p.issued[0] {
201+ return
202+ }
203+ p.owed, p.issued = false, nil
204+}
205+
179 func opacity(c css.Color, a float64) css.Color {206 func opacity(c css.Color, a float64) css.Color {
180 c.A *= math.Max(0, math.Min(1, a))207 c.A *= math.Max(0, math.Min(1, a))
181 return c208 return c
M internal/render/prepare_test.go +149 -0
@@ -90,6 +90,7 @@ func TestGlyphRasterAndAtlas(t *testing.T) {
9090 if len(f.Quads) < 10 || len(f.Uploads) == 0 {
9191 t.Fatalf("scale %g: %d glyphs %d uploads", scale, len(f.Quads), len(f.Uploads))
9292 }
93+ p.Submitted(f.Uploads)
9394 f, err = p.Prepare([]layout.Command{cmd}, scale, int(math.Ceil((measured.Width+12)*scale)), 100)
9495 if err != nil || len(f.Uploads) != 0 {
9596 t.Fatalf("scale %g: second frame uploads=%d err=%v", scale, len(f.Uploads), err)
@@ -100,3 +101,151 @@ func TestGlyphRasterAndAtlas(t *testing.T) {
100101 t.Fatalf("accepted mismatched face: %v", err)
101102 }
102103 }
104+
105+func glyphCommand(t *testing.T, str string) layout.Command {
106+ t.Helper()
107+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ measured, err := text.NewEngine(catalog).Measure(str, text.Request{Families: []string{"Noto Sans"}, Size: 20}, 0)
112+ if err != nil {
113+ t.Fatal(err)
114+ }
115+ cmd := layout.Command{Op: "text", Color: css.Color{A: 1}, Opacity: 1}
116+ for _, line := range measured.Lines {
117+ for _, shaped := range line.Runs {
118+ r := layout.Run{Face: shaped.Face, FaceID: shaped.Face.ID, Size: 20}
119+ for _, g := range shaped.Glyphs {
120+ r.Glyphs = append(r.Glyphs, layout.Glyph{ID: uint32(g.ID), X: 6 + g.X, Y: 6 + g.Y})
121+ }
122+ cmd.Runs = append(cmd.Runs, r)
123+ }
124+ }
125+ return cmd
126+}
127+
128+func uploadBytes(us []text.Upload) (n int) {
129+ for _, u := range us {
130+ n += len(u.Bytes)
131+ }
132+ return n
133+}
134+
135+func TestUploadsStayOwedUntilSubmitted(t *testing.T) {
136+ cmd := glyphCommand(t, "office")
137+ p, err := NewPreparer(256, 2)
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ prep := func() Frame {
142+ f, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100)
143+ if err != nil {
144+ t.Fatal(err)
145+ }
146+ return f
147+ }
148+ first := prep()
149+ if len(first.Uploads) == 0 {
150+ t.Fatal("no uploads")
151+ }
152+ // No submit (deferred/retried): the same placements are resent, as full pages.
153+ retry := prep()
154+ if len(retry.Uploads) == 0 || len(retry.Quads) != len(first.Quads) {
155+ t.Fatalf("retry uploads=%d quads=%d/%d", len(retry.Uploads), len(retry.Quads), len(first.Quads))
156+ }
157+ for i := range first.Quads {
158+ if first.Quads[i].Glyph != retry.Quads[i].Glyph {
159+ t.Fatal("placements moved on retry")
160+ }
161+ }
162+ if got := retry.Uploads[0].Rect; got.Dx() != 256 || got.Dy() != 256 {
163+ t.Fatalf("retry rect %v not full page", got)
164+ }
165+ // Acknowledging a stale frame's uploads is ignored.
166+ p.Submitted(first.Uploads)
167+ again := prep()
168+ if len(again.Uploads) == 0 {
169+ t.Fatal("stale acknowledgement discarded owed uploads")
170+ }
171+ // Successful submit clears the debt; an unchanged frame uploads nothing.
172+ p.Submitted(again.Uploads)
173+ if f := prep(); len(f.Uploads) != 0 {
174+ t.Fatalf("uploads after ack: %d", len(f.Uploads))
175+ }
176+ // Repeated acknowledgement is harmless.
177+ p.Submitted(again.Uploads)
178+ if f := prep(); len(f.Uploads) != 0 {
179+ t.Fatalf("uploads after repeated ack: %d", len(f.Uploads))
180+ }
181+}
182+
183+func TestSupersededFrameKeepsAbandonedAtlasChanges(t *testing.T) {
184+ a, b := glyphCommand(t, "office"), glyphCommand(t, "wxyz日本語")
185+ p, err := NewPreparer(256, 2)
186+ if err != nil {
187+ t.Fatal(err)
188+ }
189+ // Frame 1 is prepared and acknowledged: the GPU mirror holds "office".
190+ f, err := p.Prepare([]layout.Command{a}, 1, 400, 100)
191+ if err != nil {
192+ t.Fatal(err)
193+ }
194+ p.Submitted(f.Uploads)
195+ // Frame 2 adds glyphs but is abandoned before submission.
196+ old, err := p.Prepare([]layout.Command{a, b}, 1, 400, 100)
197+ if err != nil || len(old.Uploads) == 0 {
198+ t.Fatalf("old frame: %v uploads=%d", err, len(old.Uploads))
199+ }
200+ // Frame 3 does not use frame 2's glyphs, yet must carry its atlas changes.
201+ latest, err := p.Prepare([]layout.Command{a}, 1, 400, 100)
202+ if err != nil {
203+ t.Fatal(err)
204+ }
205+ if uploadBytes(latest.Uploads) < uploadBytes(old.Uploads) {
206+ t.Fatalf("latest uploads %d < abandoned %d", uploadBytes(latest.Uploads), uploadBytes(old.Uploads))
207+ }
208+ // The abandoned frame's late acknowledgement must not clear the debt.
209+ p.Submitted(old.Uploads)
210+ if next, err := p.Prepare([]layout.Command{a}, 1, 400, 100); err != nil || len(next.Uploads) == 0 {
211+ t.Fatalf("debt lost: %v uploads=%d", err, len(next.Uploads))
212+ }
213+}
214+
215+func TestFailedPrepareKeepsOwedUploads(t *testing.T) {
216+ cmd := glyphCommand(t, "office")
217+ p, err := NewPreparer(256, 2)
218+ if err != nil {
219+ t.Fatal(err)
220+ }
221+ if _, err = p.Prepare([]layout.Command{cmd}, 1, 200, 100); err != nil {
222+ t.Fatal(err)
223+ }
224+ if _, err = p.Prepare([]layout.Command{cmd, {Op: "clip-pop"}}, 1, 200, 100); err == nil {
225+ t.Fatal("expected failure")
226+ }
227+ f, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100)
228+ if err != nil || len(f.Uploads) == 0 {
229+ t.Fatalf("uploads lost after failed prepare: %v %d", err, len(f.Uploads))
230+ }
231+}
232+
233+func TestFirstPrepareFailureKeepsUndrainedAtlasChanges(t *testing.T) {
234+ cmd := glyphCommand(t, "office")
235+ p, err := NewPreparer(256, 2)
236+ if err != nil {
237+ t.Fatal(err)
238+ }
239+ // Glyphs are inserted, then the frame fails before uploads are drained.
240+ if _, err = p.Prepare([]layout.Command{cmd, {Op: "clip-pop"}}, 1, 200, 100); err == nil {
241+ t.Fatal("expected failure")
242+ }
243+ f, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100)
244+ if err != nil || len(f.Uploads) == 0 || len(f.Quads) == 0 {
245+ t.Fatalf("undrained changes lost: %v uploads=%d quads=%d", err, len(f.Uploads), len(f.Quads))
246+ }
247+ p.Submitted(f.Uploads)
248+ if g, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100); err != nil || len(g.Uploads) != 0 {
249+ t.Fatalf("uploads after ack: %v %d", err, len(g.Uploads))
250+ }
251+}
@@ -90,6 +90,7 @@ func TestGlyphRasterAndAtlas(t *testing.T) {
90 if len(f.Quads) < 10 || len(f.Uploads) == 0 {90 if len(f.Quads) < 10 || len(f.Uploads) == 0 {
91 t.Fatalf("scale %g: %d glyphs %d uploads", scale, len(f.Quads), len(f.Uploads))91 t.Fatalf("scale %g: %d glyphs %d uploads", scale, len(f.Quads), len(f.Uploads))
92 }92 }
93+ p.Submitted(f.Uploads)
93 f, err = p.Prepare([]layout.Command{cmd}, scale, int(math.Ceil((measured.Width+12)*scale)), 100)94 f, err = p.Prepare([]layout.Command{cmd}, scale, int(math.Ceil((measured.Width+12)*scale)), 100)
94 if err != nil || len(f.Uploads) != 0 {95 if err != nil || len(f.Uploads) != 0 {
95 t.Fatalf("scale %g: second frame uploads=%d err=%v", scale, len(f.Uploads), err)96 t.Fatalf("scale %g: second frame uploads=%d err=%v", scale, len(f.Uploads), err)
@@ -100,3 +101,151 @@ func TestGlyphRasterAndAtlas(t *testing.T) {
100 t.Fatalf("accepted mismatched face: %v", err)101 t.Fatalf("accepted mismatched face: %v", err)
101 }102 }
102 }103 }
104+
105+func glyphCommand(t *testing.T, str string) layout.Command {
106+ t.Helper()
107+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ measured, err := text.NewEngine(catalog).Measure(str, text.Request{Families: []string{"Noto Sans"}, Size: 20}, 0)
112+ if err != nil {
113+ t.Fatal(err)
114+ }
115+ cmd := layout.Command{Op: "text", Color: css.Color{A: 1}, Opacity: 1}
116+ for _, line := range measured.Lines {
117+ for _, shaped := range line.Runs {
118+ r := layout.Run{Face: shaped.Face, FaceID: shaped.Face.ID, Size: 20}
119+ for _, g := range shaped.Glyphs {
120+ r.Glyphs = append(r.Glyphs, layout.Glyph{ID: uint32(g.ID), X: 6 + g.X, Y: 6 + g.Y})
121+ }
122+ cmd.Runs = append(cmd.Runs, r)
123+ }
124+ }
125+ return cmd
126+}
127+
128+func uploadBytes(us []text.Upload) (n int) {
129+ for _, u := range us {
130+ n += len(u.Bytes)
131+ }
132+ return n
133+}
134+
135+func TestUploadsStayOwedUntilSubmitted(t *testing.T) {
136+ cmd := glyphCommand(t, "office")
137+ p, err := NewPreparer(256, 2)
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ prep := func() Frame {
142+ f, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100)
143+ if err != nil {
144+ t.Fatal(err)
145+ }
146+ return f
147+ }
148+ first := prep()
149+ if len(first.Uploads) == 0 {
150+ t.Fatal("no uploads")
151+ }
152+ // No submit (deferred/retried): the same placements are resent, as full pages.
153+ retry := prep()
154+ if len(retry.Uploads) == 0 || len(retry.Quads) != len(first.Quads) {
155+ t.Fatalf("retry uploads=%d quads=%d/%d", len(retry.Uploads), len(retry.Quads), len(first.Quads))
156+ }
157+ for i := range first.Quads {
158+ if first.Quads[i].Glyph != retry.Quads[i].Glyph {
159+ t.Fatal("placements moved on retry")
160+ }
161+ }
162+ if got := retry.Uploads[0].Rect; got.Dx() != 256 || got.Dy() != 256 {
163+ t.Fatalf("retry rect %v not full page", got)
164+ }
165+ // Acknowledging a stale frame's uploads is ignored.
166+ p.Submitted(first.Uploads)
167+ again := prep()
168+ if len(again.Uploads) == 0 {
169+ t.Fatal("stale acknowledgement discarded owed uploads")
170+ }
171+ // Successful submit clears the debt; an unchanged frame uploads nothing.
172+ p.Submitted(again.Uploads)
173+ if f := prep(); len(f.Uploads) != 0 {
174+ t.Fatalf("uploads after ack: %d", len(f.Uploads))
175+ }
176+ // Repeated acknowledgement is harmless.
177+ p.Submitted(again.Uploads)
178+ if f := prep(); len(f.Uploads) != 0 {
179+ t.Fatalf("uploads after repeated ack: %d", len(f.Uploads))
180+ }
181+}
182+
183+func TestSupersededFrameKeepsAbandonedAtlasChanges(t *testing.T) {
184+ a, b := glyphCommand(t, "office"), glyphCommand(t, "wxyz日本語")
185+ p, err := NewPreparer(256, 2)
186+ if err != nil {
187+ t.Fatal(err)
188+ }
189+ // Frame 1 is prepared and acknowledged: the GPU mirror holds "office".
190+ f, err := p.Prepare([]layout.Command{a}, 1, 400, 100)
191+ if err != nil {
192+ t.Fatal(err)
193+ }
194+ p.Submitted(f.Uploads)
195+ // Frame 2 adds glyphs but is abandoned before submission.
196+ old, err := p.Prepare([]layout.Command{a, b}, 1, 400, 100)
197+ if err != nil || len(old.Uploads) == 0 {
198+ t.Fatalf("old frame: %v uploads=%d", err, len(old.Uploads))
199+ }
200+ // Frame 3 does not use frame 2's glyphs, yet must carry its atlas changes.
201+ latest, err := p.Prepare([]layout.Command{a}, 1, 400, 100)
202+ if err != nil {
203+ t.Fatal(err)
204+ }
205+ if uploadBytes(latest.Uploads) < uploadBytes(old.Uploads) {
206+ t.Fatalf("latest uploads %d < abandoned %d", uploadBytes(latest.Uploads), uploadBytes(old.Uploads))
207+ }
208+ // The abandoned frame's late acknowledgement must not clear the debt.
209+ p.Submitted(old.Uploads)
210+ if next, err := p.Prepare([]layout.Command{a}, 1, 400, 100); err != nil || len(next.Uploads) == 0 {
211+ t.Fatalf("debt lost: %v uploads=%d", err, len(next.Uploads))
212+ }
213+}
214+
215+func TestFailedPrepareKeepsOwedUploads(t *testing.T) {
216+ cmd := glyphCommand(t, "office")
217+ p, err := NewPreparer(256, 2)
218+ if err != nil {
219+ t.Fatal(err)
220+ }
221+ if _, err = p.Prepare([]layout.Command{cmd}, 1, 200, 100); err != nil {
222+ t.Fatal(err)
223+ }
224+ if _, err = p.Prepare([]layout.Command{cmd, {Op: "clip-pop"}}, 1, 200, 100); err == nil {
225+ t.Fatal("expected failure")
226+ }
227+ f, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100)
228+ if err != nil || len(f.Uploads) == 0 {
229+ t.Fatalf("uploads lost after failed prepare: %v %d", err, len(f.Uploads))
230+ }
231+}
232+
233+func TestFirstPrepareFailureKeepsUndrainedAtlasChanges(t *testing.T) {
234+ cmd := glyphCommand(t, "office")
235+ p, err := NewPreparer(256, 2)
236+ if err != nil {
237+ t.Fatal(err)
238+ }
239+ // Glyphs are inserted, then the frame fails before uploads are drained.
240+ if _, err = p.Prepare([]layout.Command{cmd, {Op: "clip-pop"}}, 1, 200, 100); err == nil {
241+ t.Fatal("expected failure")
242+ }
243+ f, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100)
244+ if err != nil || len(f.Uploads) == 0 || len(f.Quads) == 0 {
245+ t.Fatalf("undrained changes lost: %v uploads=%d quads=%d", err, len(f.Uploads), len(f.Quads))
246+ }
247+ p.Submitted(f.Uploads)
248+ if g, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100); err != nil || len(g.Uploads) != 0 {
249+ t.Fatalf("uploads after ack: %v %d", err, len(g.Uploads))
250+ }
251+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 12

↑↓ moveenter openesc close