refactor(gui): deepen editor geometry and frame ownership
- commit
- 701c8cccf78f179f6c61ca01c42585f1a171fff2
- author
- brice · · 2026-09-30 03:20 UTC
- parent
- 5203207
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 99 | 99 | switch input.Kind { | |
| 100 | 100 | case "edit-pointer", "edit-word": | |
| 101 | 101 | 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) | |
| 111 | 103 | if input.Kind == "edit-word" { | |
| 112 | 104 | start, end := editor.WordAt(place) | |
| 113 | 105 | editor.Select(start, end) | |
| @@ -119,7 +111,7 @@ func (n Node) editControl(typ, label string, value *string, options []EditOption | |||
| 119 | 111 | ev.changed = editor.Insert(input.Text) || ev.changed | |
| 120 | 112 | case "edit-key": | |
| 121 | 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 | 115 | } else { | |
| 124 | 116 | editor.PreferredXValid = false | |
| 125 | 117 | 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 = false | 101 | editor.PreferredXValid = false | ||
| 102 | - | rendered := editor.Value | 102 | + | 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.changed | 111 | 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 = false | 116 | 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 | |||
| 8 | 8 | ||
| 9 | 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 | 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 | ||
| @@ -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 ( | |||
| 10 | 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 | 16 | type textBoundary struct { | |
| 14 | 17 | byteOffset int | |
| 15 | 18 | x, y float64 | |
| 19 | + | line int | |
| 20 | + | trailing bool | |
| 16 | 21 | } | |
| 17 | 22 | ||
| 18 | 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 | 79 | a, b = b, a | |
| 75 | 80 | } | |
| 76 | 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 | 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 | 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 | 103 | lines = append(lines, current) | |
| 99 | 104 | top += line.Height | |
| @@ -101,25 +106,152 @@ func textBoundaries(value string, n *layout.Result, display []layout.Command) [] | |||
| 101 | 106 | return lines | |
| 102 | 107 | } | |
| 103 | 108 | ||
| 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 { | |
| 107 | 239 | return 0 | |
| 108 | 240 | } | |
| 109 | 241 | 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 { | |
| 112 | 244 | if y >= top+shaped.Height/2 { | |
| 113 | 245 | line = i | |
| 114 | 246 | } | |
| 115 | 247 | top += shaped.Height | |
| 116 | 248 | } | |
| 117 | - | if line >= len(lines) { | |
| 118 | - | line = len(lines) - 1 | |
| 249 | + | if line >= len(g.lines) { | |
| 250 | + | line = len(g.lines) - 1 | |
| 119 | 251 | } | |
| 120 | 252 | best := 0 | |
| 121 | 253 | distance := math.Inf(1) | |
| 122 | - | for _, b := range lines[line] { | |
| 254 | + | for _, b := range g.lines[line] { | |
| 123 | 255 | if d := math.Abs(b.x - x); d < distance { | |
| 124 | 256 | distance = d | |
| 125 | 257 | 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 int | 17 | byteOffset int | ||
| 15 | x, y float64 | 18 | 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, a | 79 | 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.Height | 104 | top += line.Height | ||
| @@ -101,25 +106,152 @@ func textBoundaries(value string, n *layout.Result, display []layout.Command) [] | |||||
| 101 | return lines | 106 | 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 0 | 239 | return 0 | ||
| 108 | } | 240 | } | ||
| 109 | line := 0 | 241 | line := 0 | ||
| 110 | - | top := n.Content.Y | 242 | + | 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 = i | 245 | line = i | ||
| 114 | } | 246 | } | ||
| 115 | top += shaped.Height | 247 | top += shaped.Height | ||
| 116 | } | 248 | } | ||
| 117 | - | if line >= len(lines) { | 249 | + | if line >= len(g.lines) { |
| 118 | - | line = len(lines) - 1 | 250 | + | line = len(g.lines) - 1 |
| 119 | } | 251 | } | ||
| 120 | best := 0 | 252 | 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 = d | 256 | distance = d | ||
| 125 | best = b.byteOffset | 257 | 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 ( | |||
| 9 | 9 | ||
| 10 | 10 | // moveVisualLine uses the last committed wrap geometry; preferred X survives | |
| 11 | 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) { | |
| 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 | |
| 14 | 15 | if len(lines) == 0 { | |
| 15 | 16 | return | |
| 16 | 17 | } | |
| @@ -22,14 +23,12 @@ func moveVisualLine(s *edit.State, n *layout.Result, display []layout.Command, d | |||
| 22 | 23 | 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 | 29 | 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 | |
| 33 | 32 | } | |
| 34 | 33 | s.PreferredXValid = true | |
| 35 | 34 | } | |
| @@ -9,8 +9,9 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | // moveVisualLine uses the last committed wrap geometry; preferred X survives | 10 | // 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 | return | 16 | return | ||
| 16 | } | 17 | } | ||
| @@ -22,14 +23,12 @@ func moveVisualLine(s *edit.State, n *layout.Result, display []layout.Command, d | |||||
| 22 | break | 23 | 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 = true | 33 | s.PreferredXValid = true | ||
| 35 | } | 34 | } | ||
M edit_paint.go +11 -43
| @@ -43,37 +43,20 @@ func (r *runtime) paintEditors(root *element, out *layout.Output) { | |||
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | // 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. | |
| 48 | 48 | func editorDecoration(s *edit.State, n *layout.Result, display []layout.Command, password, focused bool) []layout.Command { | |
| 49 | 49 | if n == nil { | |
| 50 | 50 | return nil | |
| 51 | 51 | } | |
| 52 | - | value := s.Value | |
| 53 | 52 | 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 | |
| 72 | 55 | var result []layout.Command | |
| 73 | 56 | for i, line := range lines { | |
| 74 | 57 | h := 0.0 | |
| 75 | 58 | if i < len(n.Lines) { | |
| 76 | - | h = n.Lines[i].Height | |
| 59 | + | h = geometry.lineHeight(i) | |
| 77 | 60 | } | |
| 78 | 61 | for j := 0; j+1 < len(line); j += 2 { | |
| 79 | 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 | 79 | 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 { | |
| 109 | 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 { | |
| 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}) | |
| 120 | 88 | } | |
| 121 | 89 | } | |
| 122 | 90 | 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 nil | 50 | return nil | ||
| 51 | } | 51 | } | ||
| 52 | - | value := s.Value | |||
| 53 | start, end := s.Range() | 52 | start, end := s.Range() | ||
| 54 | - | cursor := s.Cursor | 53 | + | 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.Command | 55 | var result []layout.Command | ||
| 73 | for i, line := range lines { | 56 | for i, line := range lines { | ||
| 74 | h := 0.0 | 57 | h := 0.0 | ||
| 75 | if i < len(n.Lines) { | 58 | if i < len(n.Lines) { | ||
| 76 | - | h = n.Lines[i].Height | 59 | + | 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 *textBoundary | 80 | + | // Selection and caret share the geometry's caret policy: the trailing |
| 98 | - | // The trailing edge of the preceding character takes priority over the | 81 | + | // 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.H | 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}) |
| 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 result | 90 | 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 | |||
| 71 | 71 | if s.Preedit == "" || n == nil || e.computed == nil { | |
| 72 | 72 | return nil | |
| 73 | 73 | } | |
| 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) | |
| 96 | 77 | x, y := n.Content.X, n.Content.Y | |
| 97 | - | if origin != nil { | |
| 78 | + | if found { | |
| 98 | 79 | x, y = origin.x, origin.y | |
| 99 | 80 | } | |
| 100 | 81 | st := e.computed.Style | |
| @@ -128,18 +109,9 @@ func preeditCommands(s *edit.State, n *layout.Result, e *element, engine *text.E | |||
| 128 | 109 | clamped = b | |
| 129 | 110 | } | |
| 130 | 111 | 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) | |
| 143 | 115 | // The preedit cursor uses shaped cluster advances, not raw rune count. | |
| 144 | 116 | px := x | |
| 145 | 117 | 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 nil | 72 | return nil | ||
| 73 | } | 73 | } | ||
| 74 | - | value := s.Value | 74 | + | // The composition starts at the same caret position that is painted. |
| 75 | - | cursor := s.Cursor | 75 | + | 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.Y | 77 | x, y := n.Content.X, n.Content.Y | ||
| 97 | - | if origin != nil { | 78 | + | if found { |
| 98 | x, y = origin.x, origin.y | 79 | x, y = origin.x, origin.y | ||
| 99 | } | 80 | } | ||
| 100 | st := e.computed.Style | 81 | st := e.computed.Style | ||
| @@ -128,18 +109,9 @@ func preeditCommands(s *edit.State, n *layout.Result, e *element, engine *text.E | |||||
| 128 | clamped = b | 109 | clamped = b | ||
| 129 | } | 110 | } | ||
| 130 | pos = clamped | 111 | 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 uses | 113 | + | // 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 := x | 116 | 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 { | |||
| 35 | 35 | if s == nil || n == nil { | |
| 36 | 36 | return false | |
| 37 | 37 | } | |
| 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 { | |
| 53 | 41 | return false | |
| 54 | 42 | } | |
| 55 | 43 | old := r.state.scroll[id] | |
| @@ -62,10 +50,7 @@ func (r *runtime) scrollCaret(root *element, out *layout.Output) bool { | |||
| 62 | 50 | } | |
| 63 | 51 | next.H = 0 | |
| 64 | 52 | } else { | |
| 65 | - | height := 0.0 | |
| 66 | - | if len(n.Lines) > 0 { | |
| 67 | - | height = n.Lines[0].Height | |
| 68 | - | } | |
| 53 | + | height := geometry.lineHeight(caret.line) | |
| 69 | 54 | if caret.y < n.Content.Y { | |
| 70 | 55 | next.H += caret.y - n.Content.Y | |
| 71 | 56 | } 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 false | 36 | return false | ||
| 37 | } | 37 | } | ||
| 38 | - | rendered := e.text | 38 | + | geometry := newEditorGeometry(s, n, out.Display, e.password) |
| 39 | - | cursor := s.Cursor | 39 | + | 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 false | 41 | 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 = 0 | 51 | next.H = 0 | ||
| 64 | } else { | 52 | } else { | ||
| 65 | - | height := 0.0 | 53 | + | 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.Y | 55 | 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 { | |||
| 40 | 40 | } | |
| 41 | 41 | defer func() { s.waitCancel(); s.waiters.Wait() }() | |
| 42 | 42 | wlEvents := s.Window.StartReader() | |
| 43 | - | dirty := true | |
| 44 | - | var prepared render.Frame | |
| 45 | - | havePrepared := false | |
| 43 | + | sched := newFrameSchedule() | |
| 46 | 44 | for !s.Window.Closed { | |
| 47 | 45 | if err := ctx.Err(); err != nil { | |
| 48 | 46 | return err | |
| @@ -51,55 +49,40 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error { | |||
| 51 | 49 | if err != nil { | |
| 52 | 50 | 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 | 54 | if s.Window.Dirty { | |
| 55 | - | dirty = true | |
| 55 | + | sched.Invalidate() | |
| 56 | 56 | } | |
| 57 | 57 | if inputs := s.Window.DrainInput(nil); len(inputs) > 0 { | |
| 58 | - | dirty = true | |
| 59 | - | havePrepared = false | |
| 58 | + | sched.Invalidate() | |
| 60 | 59 | if app.Input != nil { | |
| 61 | 60 | if err := app.Input(inputs); err != nil { | |
| 62 | 61 | return err | |
| 63 | 62 | } | |
| 64 | 63 | } | |
| 65 | 64 | } | |
| 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) | |
| 77 | 69 | } | |
| 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 { | |
| 86 | 78 | 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 | 83 | 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) | |
| 103 | 86 | } | |
| 104 | 87 | if adapter := s.Window.InputAdapter(); adapter != nil { | |
| 105 | 88 | if at, ok := adapter.NextRepeat(); ok { | |
| @@ -110,14 +93,12 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error { | |||
| 110 | 93 | case <-ctx.Done(): | |
| 111 | 94 | return ctx.Err() | |
| 112 | 95 | case <-app.Wake: | |
| 113 | - | dirty = true | |
| 114 | - | havePrepared = false | |
| 96 | + | sched.Invalidate() | |
| 115 | 97 | case <-retry: | |
| 116 | 98 | case <-repeat: | |
| 117 | 99 | if adapter := s.Window.InputAdapter(); adapter != nil { | |
| 118 | 100 | if input, ok := adapter.Tick(time.Now()); ok { | |
| 119 | - | dirty = true | |
| 120 | - | havePrepared = false | |
| 101 | + | sched.Invalidate() | |
| 121 | 102 | if app.Input != nil { | |
| 122 | 103 | if err := app.Input([]wayland.Input{input}); err != nil { | |
| 123 | 104 | return err | |
| @@ -141,10 +122,6 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error { | |||
| 141 | 122 | return err | |
| 142 | 123 | } | |
| 143 | 124 | } | |
| 144 | - | if s.Window.Dirty { | |
| 145 | - | dirty = true | |
| 146 | - | havePrepared = false | |
| 147 | - | } | |
| 148 | 125 | // DrainInput is handled at the start of the next iteration. | |
| 149 | 126 | case ev := <-s.releases: | |
| 150 | 127 | 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 := true | 43 | + | 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 err | 46 | 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 err | 50 | 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 = true | 55 | + | 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 = true | 58 | + | 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 err | 61 | 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 err | 78 | 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.Time | 83 | 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 = true | 96 | + | 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 = true | 101 | + | 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 err | 104 | return err | ||
| @@ -141,10 +122,6 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error { | |||||
| 141 | return err | 122 | 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) | |||
| 343 | 343 | } | |
| 344 | 344 | ||
| 345 | 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 | 346 | started := time.Now() | |
| 354 | 347 | if s.Window.Dirty { | |
| 355 | 348 | w, h, err := s.Window.PhysicalSize() | |
| @@ -402,7 +395,8 @@ func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploa | |||
| 402 | 395 | if err = slot.Frame.Submit(slot.Wait); err != nil { | |
| 403 | 396 | return false, err | |
| 404 | 397 | } | |
| 405 | - | submitted = true | |
| 398 | + | // The render module keeps uploads owed until this acknowledgement. | |
| 399 | + | s.Preparer.Submitted(uploads) | |
| 406 | 400 | if slot.Wait != nil { | |
| 407 | 401 | slot.WaitInFlight = true | |
| 408 | 402 | } | |
| @@ -677,8 +671,8 @@ func (s *Session) RunWithCommit(ctx context.Context, frames int, draw func(int) | |||
| 677 | 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 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. | |
| 682 | 676 | func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, draw func(int) (render.Frame, error), committed func(int) error) (int, error) { | |
| 683 | 677 | if draw == nil { | |
| 684 | 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 | 685 | instances, batches, stats := render.ListBatches(frame) | |
| 692 | 686 | if len(stats.Skipped) != 0 { | |
| 693 | - | s.Preparer.Atlas.MarkAllDirty() | |
| 694 | 687 | return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped) | |
| 695 | 688 | } | |
| 696 | 689 | 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, err | 396 | return false, err | ||
| 404 | } | 397 | } | ||
| 405 | - | submitted = true | 398 | + | // 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 = true | 401 | 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 skipped | 674 | + | // 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) { | |||
| 39 | 39 | if err != nil { | |
| 40 | 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 | 44 | b.Fatal(err) | |
| 44 | 45 | } | |
| 46 | + | p.Submitted(warm.Uploads) | |
| 45 | 47 | b.ReportAllocs() | |
| 46 | 48 | b.ResetTimer() | |
| 47 | 49 | for i := 0; i < b.N; i++ { | |
| @@ -49,5 +51,6 @@ func BenchmarkPrepare(b *testing.B) { | |||
| 49 | 51 | if err != nil || len(frame.Quads) == 0 { | |
| 50 | 52 | b.Fatalf("prepare: %v", err) | |
| 51 | 53 | } | |
| 54 | + | p.Submitted(frame.Uploads) | |
| 52 | 55 | } | |
| 53 | 56 | } | |
| @@ -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 { | |||
| 38 | 38 | 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 | 53 | // NewPreparer bounds the grayscale glyph atlas. Call Prepare synchronously with | |
| 44 | 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 | 182 | if len(stack) > 0 { | |
| 173 | 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 | 190 | frame.Uploads = p.Atlas.Uploads() | |
| 191 | + | p.issued, p.owed = frame.Uploads, len(frame.Uploads) > 0 | |
| 176 | 192 | 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 | 206 | func opacity(c css.Color, a float64) css.Color { | |
| 180 | 207 | c.A *= math.Max(0, math.Min(1, a)) | |
| 181 | 208 | return c | |
| @@ -38,7 +38,17 @@ type Frame struct { | |||||
| 38 | Uploads []text.Upload | 38 | 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 with | 53 | // 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, nil | 192 | 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 c | 208 | return c | ||
M internal/render/prepare_test.go +149 -0
| @@ -90,6 +90,7 @@ func TestGlyphRasterAndAtlas(t *testing.T) { | |||
| 90 | 90 | if len(f.Quads) < 10 || len(f.Uploads) == 0 { | |
| 91 | 91 | t.Fatalf("scale %g: %d glyphs %d uploads", scale, len(f.Quads), len(f.Uploads)) | |
| 92 | 92 | } | |
| 93 | + | p.Submitted(f.Uploads) | |
| 93 | 94 | f, err = p.Prepare([]layout.Command{cmd}, scale, int(math.Ceil((measured.Width+12)*scale)), 100) | |
| 94 | 95 | if err != nil || len(f.Uploads) != 0 { | |
| 95 | 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 | 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 | + | } | |
| @@ -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 | + | } | |||