fix(gui): retain blocked frames across no-op input
- commit
- d438c5c2824bd8505600c6843b199971f1c9cf6d
- author
- brice · · 2026-09-30 03:31 UTC
- parent
- 701c8cc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +134 -40
A edit_geometry_review_test.go +88 -0
| @@ -0,0 +1,88 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/nefergui/internal/edit" | |
| 9 | + | "github.com/bnema/nefergui/internal/layout" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestRTLParagraphCaretUsesPrecedingTrailingEdge(t *testing.T) { | |
| 13 | + | value := "مرحبا abc" | |
| 14 | + | r := geometryRuntime(t, `input {width:300px;height:28px;font-size:16px}`) | |
| 15 | + | view := func(f *Frame) { f.Root().Input("mixed", &value) } | |
| 16 | + | r.Build(view) | |
| 17 | + | r.route(key("Tab")) | |
| 18 | + | r.Build(view) | |
| 19 | + | n := r.output.Tree.Children[0] | |
| 20 | + | offset := len("مرحبا ") | |
| 21 | + | s := r.edits[n.ID] | |
| 22 | + | s.Move(offset, false) | |
| 23 | + | s.Preedit = "x" | |
| 24 | + | r.Redraw() | |
| 25 | + | r.Build(view) | |
| 26 | + | n = r.output.Tree.Children[0] | |
| 27 | + | want := rawTrailingEdge(t, r, n, 5) | |
| 28 | + | g := newEditorGeometry(s, n, r.output.Display, false) | |
| 29 | + | var first *textBoundary | |
| 30 | + | for i := range g.lines[0] { | |
| 31 | + | if g.lines[0][i].byteOffset == offset { | |
| 32 | + | first = &g.lines[0][i] | |
| 33 | + | break | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | if first == nil || first.trailing || math.Abs(first.x-want) < 1 { | |
| 37 | + | t.Fatalf("fixture must encounter a distinct leading edge first: first=%+v trailing=%g", first, want) | |
| 38 | + | } | |
| 39 | + | for _, op := range []string{"caret", "preedit-text"} { | |
| 40 | + | cmd, ok := displayCommand(r, n.ID, op) | |
| 41 | + | if !ok || math.Abs(cmd.Rect.X-want) > .01 { | |
| 42 | + | t.Fatalf("%s x=%g want %g", op, cmd.Rect.X, want) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | // Place the right viewport edge between the two positions. Only the correct | |
| 46 | + | // trailing edge should require a horizontal scroll. | |
| 47 | + | if want <= first.x { | |
| 48 | + | t.Fatalf("fixture must place trailing edge right of leading edge") | |
| 49 | + | } | |
| 50 | + | n.Content.W = (first.x+want)/2 - n.Content.X | |
| 51 | + | n.ContentSize.W = 500 | |
| 52 | + | r.state.scroll = map[string]layout.Size{n.ID: {}} | |
| 53 | + | expected := want - (n.Content.X + n.Content.W - 1) | |
| 54 | + | if !r.scrollCaret(r.committed, &r.output) || math.Abs(r.state.scroll[n.ID].W-expected) > .01 { | |
| 55 | + | t.Fatalf("scroll=%+v want %g", r.state.scroll[n.ID], expected) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestPasswordWrappedNavigationUsesLogicalGraphemeOffsets(t *testing.T) { | |
| 60 | + | grapheme := "e\u0301" | |
| 61 | + | value := strings.Repeat(grapheme, 24) | |
| 62 | + | r := geometryRuntime(t, `textarea {width:45px;height:100px;font-size:16px}`) | |
| 63 | + | view := func(f *Frame) { f.Root().Textarea("secret", &value, Password(true)) } | |
| 64 | + | r.Build(view) | |
| 65 | + | r.route(key("Tab")) | |
| 66 | + | r.Build(view) | |
| 67 | + | n := r.output.Tree.Children[0] | |
| 68 | + | if len(n.Lines) < 3 { | |
| 69 | + | t.Fatalf("need wrapped password: %d lines", len(n.Lines)) | |
| 70 | + | } | |
| 71 | + | s := r.edits[n.ID] | |
| 72 | + | s.Move(0, false) | |
| 73 | + | s.PreferredXValid = false | |
| 74 | + | // Each shaped source rune is one bullet, hence one logical grapheme. The | |
| 75 | + | // starts of the masked runs independently determine the expected offsets. | |
| 76 | + | logical := edit.Boundaries(value) | |
| 77 | + | want := logical[n.Lines[1].Runs[0].Start] | |
| 78 | + | r.route(key("Down")) | |
| 79 | + | r.Build(view) | |
| 80 | + | if s.Cursor != want { | |
| 81 | + | t.Fatalf("down cursor=%d want logical offset %d", s.Cursor, want) | |
| 82 | + | } | |
| 83 | + | r.route(key("Up")) | |
| 84 | + | r.Build(view) | |
| 85 | + | if s.Cursor != 0 { | |
| 86 | + | t.Fatalf("up cursor=%d want 0", s.Cursor) | |
| 87 | + | } | |
| 88 | + | } | |
| @@ -0,0 +1,88 @@ | |||||
| 1 | + | package nefergui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/nefergui/internal/edit" | |||
| 9 | + | "github.com/bnema/nefergui/internal/layout" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestRTLParagraphCaretUsesPrecedingTrailingEdge(t *testing.T) { | |||
| 13 | + | value := "مرحبا abc" | |||
| 14 | + | r := geometryRuntime(t, `input {width:300px;height:28px;font-size:16px}`) | |||
| 15 | + | view := func(f *Frame) { f.Root().Input("mixed", &value) } | |||
| 16 | + | r.Build(view) | |||
| 17 | + | r.route(key("Tab")) | |||
| 18 | + | r.Build(view) | |||
| 19 | + | n := r.output.Tree.Children[0] | |||
| 20 | + | offset := len("مرحبا ") | |||
| 21 | + | s := r.edits[n.ID] | |||
| 22 | + | s.Move(offset, false) | |||
| 23 | + | s.Preedit = "x" | |||
| 24 | + | r.Redraw() | |||
| 25 | + | r.Build(view) | |||
| 26 | + | n = r.output.Tree.Children[0] | |||
| 27 | + | want := rawTrailingEdge(t, r, n, 5) | |||
| 28 | + | g := newEditorGeometry(s, n, r.output.Display, false) | |||
| 29 | + | var first *textBoundary | |||
| 30 | + | for i := range g.lines[0] { | |||
| 31 | + | if g.lines[0][i].byteOffset == offset { | |||
| 32 | + | first = &g.lines[0][i] | |||
| 33 | + | break | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | if first == nil || first.trailing || math.Abs(first.x-want) < 1 { | |||
| 37 | + | t.Fatalf("fixture must encounter a distinct leading edge first: first=%+v trailing=%g", first, want) | |||
| 38 | + | } | |||
| 39 | + | for _, op := range []string{"caret", "preedit-text"} { | |||
| 40 | + | cmd, ok := displayCommand(r, n.ID, op) | |||
| 41 | + | if !ok || math.Abs(cmd.Rect.X-want) > .01 { | |||
| 42 | + | t.Fatalf("%s x=%g want %g", op, cmd.Rect.X, want) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | // Place the right viewport edge between the two positions. Only the correct | |||
| 46 | + | // trailing edge should require a horizontal scroll. | |||
| 47 | + | if want <= first.x { | |||
| 48 | + | t.Fatalf("fixture must place trailing edge right of leading edge") | |||
| 49 | + | } | |||
| 50 | + | n.Content.W = (first.x+want)/2 - n.Content.X | |||
| 51 | + | n.ContentSize.W = 500 | |||
| 52 | + | r.state.scroll = map[string]layout.Size{n.ID: {}} | |||
| 53 | + | expected := want - (n.Content.X + n.Content.W - 1) | |||
| 54 | + | if !r.scrollCaret(r.committed, &r.output) || math.Abs(r.state.scroll[n.ID].W-expected) > .01 { | |||
| 55 | + | t.Fatalf("scroll=%+v want %g", r.state.scroll[n.ID], expected) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestPasswordWrappedNavigationUsesLogicalGraphemeOffsets(t *testing.T) { | |||
| 60 | + | grapheme := "e\u0301" | |||
| 61 | + | value := strings.Repeat(grapheme, 24) | |||
| 62 | + | r := geometryRuntime(t, `textarea {width:45px;height:100px;font-size:16px}`) | |||
| 63 | + | view := func(f *Frame) { f.Root().Textarea("secret", &value, Password(true)) } | |||
| 64 | + | r.Build(view) | |||
| 65 | + | r.route(key("Tab")) | |||
| 66 | + | r.Build(view) | |||
| 67 | + | n := r.output.Tree.Children[0] | |||
| 68 | + | if len(n.Lines) < 3 { | |||
| 69 | + | t.Fatalf("need wrapped password: %d lines", len(n.Lines)) | |||
| 70 | + | } | |||
| 71 | + | s := r.edits[n.ID] | |||
| 72 | + | s.Move(0, false) | |||
| 73 | + | s.PreferredXValid = false | |||
| 74 | + | // Each shaped source rune is one bullet, hence one logical grapheme. The | |||
| 75 | + | // starts of the masked runs independently determine the expected offsets. | |||
| 76 | + | logical := edit.Boundaries(value) | |||
| 77 | + | want := logical[n.Lines[1].Runs[0].Start] | |||
| 78 | + | r.route(key("Down")) | |||
| 79 | + | r.Build(view) | |||
| 80 | + | if s.Cursor != want { | |||
| 81 | + | t.Fatalf("down cursor=%d want logical offset %d", s.Cursor, want) | |||
| 82 | + | } | |||
| 83 | + | r.route(key("Up")) | |||
| 84 | + | r.Build(view) | |||
| 85 | + | if s.Cursor != 0 { | |||
| 86 | + | t.Fatalf("up cursor=%d want 0", s.Cursor) | |||
| 87 | + | } | |||
| 88 | + | } | |||
M edit_paint.go +3 -6
| @@ -54,10 +54,7 @@ func editorDecoration(s *edit.State, n *layout.Result, display []layout.Command, | |||
| 54 | 54 | lines := geometry.lines | |
| 55 | 55 | var result []layout.Command | |
| 56 | 56 | for i, line := range lines { | |
| 57 | - | h := 0.0 | |
| 58 | - | if i < len(n.Lines) { | |
| 59 | - | h = geometry.lineHeight(i) | |
| 60 | - | } | |
| 57 | + | h := geometry.lineHeight(i) | |
| 61 | 58 | for j := 0; j+1 < len(line); j += 2 { | |
| 62 | 59 | a, b := line[j], line[j+1] | |
| 63 | 60 | if a.byteOffset == b.byteOffset { | |
| @@ -77,8 +74,8 @@ func editorDecoration(s *edit.State, n *layout.Result, display []layout.Command, | |||
| 77 | 74 | } | |
| 78 | 75 | } | |
| 79 | 76 | if focused { | |
| 80 | - | // Selection and caret share the geometry's caret policy: the trailing | |
| 81 | - | // edge of the preceding character wins over a later duplicate offset. | |
| 77 | + | // The caret uses the preceding character's trailing edge when an | |
| 78 | + | // offset has several visual positions. | |
| 82 | 79 | point, found := geometry.caret(s.Cursor) | |
| 83 | 80 | if !found && len(lines) == 0 { | |
| 84 | 81 | 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}) | |
| @@ -54,10 +54,7 @@ func editorDecoration(s *edit.State, n *layout.Result, display []layout.Command, | |||||
| 54 | lines := geometry.lines | 54 | lines := geometry.lines | ||
| 55 | var result []layout.Command | 55 | var result []layout.Command | ||
| 56 | for i, line := range lines { | 56 | for i, line := range lines { | ||
| 57 | - | h := 0.0 | 57 | + | h := geometry.lineHeight(i) |
| 58 | - | if i < len(n.Lines) { | |||
| 59 | - | h = geometry.lineHeight(i) | |||
| 60 | - | } | |||
| 61 | for j := 0; j+1 < len(line); j += 2 { | 58 | for j := 0; j+1 < len(line); j += 2 { | ||
| 62 | a, b := line[j], line[j+1] | 59 | a, b := line[j], line[j+1] | ||
| 63 | if a.byteOffset == b.byteOffset { | 60 | if a.byteOffset == b.byteOffset { | ||
| @@ -77,8 +74,8 @@ func editorDecoration(s *edit.State, n *layout.Result, display []layout.Command, | |||||
| 77 | } | 74 | } | ||
| 78 | } | 75 | } | ||
| 79 | if focused { | 76 | if focused { | ||
| 80 | - | // Selection and caret share the geometry's caret policy: the trailing | 77 | + | // The caret uses the preceding character's trailing edge when an |
| 81 | - | // edge of the preceding character wins over a later duplicate offset. | 78 | + | // offset has several visual positions. |
| 82 | point, found := geometry.caret(s.Cursor) | 79 | point, found := geometry.caret(s.Cursor) | ||
| 83 | if !found && len(lines) == 0 { | 80 | if !found && len(lines) == 0 { | ||
| 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}) | 81 | 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}) | ||
M internal/presentation/session/schedule.go +15 -12
| @@ -15,28 +15,29 @@ const retryInterval = 5 * time.Millisecond | |||
| 15 | 15 | // frameSchedule is RunApp's frame state, touched only from the owner loop. | |
| 16 | 16 | // A frame is wanted (dirty) until the compositor accepted a commit or the app | |
| 17 | 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. | |
| 18 | + | // a retry does not call Draw again. Input, a wake, resize or scale changes ask | |
| 19 | + | // Draw for a replacement; an unchanged draw retains the unsubmitted frame. | |
| 20 | 20 | type frameSchedule struct { | |
| 21 | 21 | dirty bool | |
| 22 | 22 | prepared render.Frame | |
| 23 | 23 | have bool | |
| 24 | + | stale bool | |
| 24 | 25 | } | |
| 25 | 26 | ||
| 26 | 27 | // newFrameSchedule starts dirty so the first configured frame is drawn. | |
| 27 | 28 | func newFrameSchedule() *frameSchedule { return &frameSchedule{dirty: true} } | |
| 28 | 29 | ||
| 29 | - | // Invalidate records that the UI or surface changed: draw again, and never | |
| 30 | - | // submit a previously prepared frame. | |
| 30 | + | // Invalidate asks Draw to refresh the UI before submitting. Keep any held | |
| 31 | + | // frame until Draw actually replaces it: no-op input must not lose a frame. | |
| 31 | 32 | func (f *frameSchedule) Invalidate() { | |
| 32 | 33 | f.dirty = true | |
| 33 | - | f.have = false | |
| 34 | - | f.prepared = render.Frame{} | |
| 34 | + | f.stale = true | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | 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 | |
| 38 | + | // compositor allows one (ready). Draw runs when no frame is held or it is | |
| 39 | + | // stale; an unchanged draw keeps a held frame, or goes idle without one. | |
| 40 | + | // submit reports whether the frame was | |
| 40 | 41 | // committed: on false the prepared frame is kept for the next attempt, on true | |
| 41 | 42 | // the schedule is clean until the next Invalidate. | |
| 42 | 43 | func (f *frameSchedule) Step( | |
| @@ -47,22 +48,24 @@ func (f *frameSchedule) Step( | |||
| 47 | 48 | if !f.dirty || !ready { | |
| 48 | 49 | return false, nil | |
| 49 | 50 | } | |
| 50 | - | if !f.have { | |
| 51 | + | if !f.have || f.stale { | |
| 51 | 52 | frame, changed, err := draw() | |
| 52 | 53 | if err != nil { | |
| 53 | 54 | return false, err | |
| 54 | 55 | } | |
| 55 | - | if !changed { | |
| 56 | + | f.stale = false | |
| 57 | + | if changed { | |
| 58 | + | f.prepared, f.have = frame, true | |
| 59 | + | } else if !f.have { | |
| 56 | 60 | f.dirty = false | |
| 57 | 61 | return false, nil | |
| 58 | 62 | } | |
| 59 | - | f.prepared, f.have = frame, true | |
| 60 | 63 | } | |
| 61 | 64 | committed, err = submit(f.prepared) | |
| 62 | 65 | if err != nil || !committed { | |
| 63 | 66 | return false, err | |
| 64 | 67 | } | |
| 65 | - | f.dirty, f.have, f.prepared = false, false, render.Frame{} | |
| 68 | + | f.dirty, f.have, f.stale, f.prepared = false, false, false, render.Frame{} | |
| 66 | 69 | return true, nil | |
| 67 | 70 | } | |
| 68 | 71 | ||
| @@ -15,28 +15,29 @@ const retryInterval = 5 * time.Millisecond | |||||
| 15 | // frameSchedule is RunApp's frame state, touched only from the owner loop. | 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 | 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 | 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 | 18 | + | // a retry does not call Draw again. Input, a wake, resize or scale changes ask |
| 19 | - | // resize or a scale change makes it stale. | 19 | + | // Draw for a replacement; an unchanged draw retains the unsubmitted frame. |
| 20 | type frameSchedule struct { | 20 | type frameSchedule struct { | ||
| 21 | dirty bool | 21 | dirty bool | ||
| 22 | prepared render.Frame | 22 | prepared render.Frame | ||
| 23 | have bool | 23 | have bool | ||
| 24 | + | stale bool | |||
| 24 | } | 25 | } | ||
| 25 | 26 | ||||
| 26 | // newFrameSchedule starts dirty so the first configured frame is drawn. | 27 | // newFrameSchedule starts dirty so the first configured frame is drawn. | ||
| 27 | func newFrameSchedule() *frameSchedule { return &frameSchedule{dirty: true} } | 28 | func newFrameSchedule() *frameSchedule { return &frameSchedule{dirty: true} } | ||
| 28 | 29 | ||||
| 29 | - | // Invalidate records that the UI or surface changed: draw again, and never | 30 | + | // Invalidate asks Draw to refresh the UI before submitting. Keep any held |
| 30 | - | // submit a previously prepared frame. | 31 | + | // frame until Draw actually replaces it: no-op input must not lose a frame. |
| 31 | func (f *frameSchedule) Invalidate() { | 32 | func (f *frameSchedule) Invalidate() { | ||
| 32 | f.dirty = true | 33 | f.dirty = true | ||
| 33 | - | f.have = false | 34 | + | f.stale = true |
| 34 | - | f.prepared = render.Frame{} | |||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | // Step attempts one frame. Nothing is drawn unless a frame is wanted and the | 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; | 38 | + | // compositor allows one (ready). Draw runs when no frame is held or it is |
| 39 | - | // an unchanged draw clears dirty. submit reports whether the frame was | 39 | + | // stale; an unchanged draw keeps a held frame, or goes idle without one. |
| 40 | + | // submit reports whether the frame was | |||
| 40 | // committed: on false the prepared frame is kept for the next attempt, on true | 41 | // committed: on false the prepared frame is kept for the next attempt, on true | ||
| 41 | // the schedule is clean until the next Invalidate. | 42 | // the schedule is clean until the next Invalidate. | ||
| 42 | func (f *frameSchedule) Step( | 43 | func (f *frameSchedule) Step( | ||
| @@ -47,22 +48,24 @@ func (f *frameSchedule) Step( | |||||
| 47 | if !f.dirty || !ready { | 48 | if !f.dirty || !ready { | ||
| 48 | return false, nil | 49 | return false, nil | ||
| 49 | } | 50 | } | ||
| 50 | - | if !f.have { | 51 | + | if !f.have || f.stale { |
| 51 | frame, changed, err := draw() | 52 | frame, changed, err := draw() | ||
| 52 | if err != nil { | 53 | if err != nil { | ||
| 53 | return false, err | 54 | return false, err | ||
| 54 | } | 55 | } | ||
| 55 | - | if !changed { | 56 | + | f.stale = false |
| 57 | + | if changed { | |||
| 58 | + | f.prepared, f.have = frame, true | |||
| 59 | + | } else if !f.have { | |||
| 56 | f.dirty = false | 60 | f.dirty = false | ||
| 57 | return false, nil | 61 | return false, nil | ||
| 58 | } | 62 | } | ||
| 59 | - | f.prepared, f.have = frame, true | |||
| 60 | } | 63 | } | ||
| 61 | committed, err = submit(f.prepared) | 64 | committed, err = submit(f.prepared) | ||
| 62 | if err != nil || !committed { | 65 | if err != nil || !committed { | ||
| 63 | return false, err | 66 | return false, err | ||
| 64 | } | 67 | } | ||
| 65 | - | f.dirty, f.have, f.prepared = false, false, render.Frame{} | 68 | + | f.dirty, f.have, f.stale, f.prepared = false, false, false, render.Frame{} |
| 66 | return true, nil | 69 | return true, nil | ||
| 67 | } | 70 | } | ||
| 68 | 71 | ||||
M internal/presentation/session/schedule_test.go +28 -22
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/nefergui/internal/platform/wayland" | |
| 10 | 9 | "github.com/bnema/nefergui/internal/render" | |
| 11 | 10 | ) | |
| 12 | 11 | ||
| @@ -61,25 +60,32 @@ func TestScheduleBlockedFrameKeepsPreparedAndRetries(t *testing.T) { | |||
| 61 | 60 | } | |
| 62 | 61 | } | |
| 63 | 62 | ||
| 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 | - | }) | |
| 63 | + | func TestScheduleInvalidateReplacesChangedFrame(t *testing.T) { | |
| 64 | + | h := newHarness(t) | |
| 65 | + | h.blocked = true | |
| 66 | + | h.step() | |
| 67 | + | h.s.Invalidate() | |
| 68 | + | h.blocked = false | |
| 69 | + | if !h.step() || h.draws != 2 || h.submits[len(h.submits)-1] != 2 { | |
| 70 | + | t.Fatalf("stale frame submitted: draws=%d submits=%v", h.draws, h.submits) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestScheduleNoOpInputKeepsUnsubmittedFrame(t *testing.T) { | |
| 75 | + | h := newHarness(t) | |
| 76 | + | h.blocked = true | |
| 77 | + | h.step() | |
| 78 | + | h.s.Invalidate() | |
| 79 | + | h.changed = false | |
| 80 | + | if h.step() || h.draws != 2 || h.submits[len(h.submits)-1] != 1 { | |
| 81 | + | t.Fatalf("no-op lost held frame: draws=%d submits=%v", h.draws, h.submits) | |
| 82 | + | } | |
| 83 | + | h.blocked = false | |
| 84 | + | if !h.step() || h.draws != 2 || h.submits[len(h.submits)-1] != 1 { | |
| 85 | + | t.Fatalf("release lost held frame: draws=%d submits=%v", h.draws, h.submits) | |
| 86 | + | } | |
| 87 | + | if h.s.NeedsRetry(false, true) { | |
| 88 | + | t.Fatal("committed schedule did not go idle") | |
| 83 | 89 | } | |
| 84 | 90 | } | |
| 85 | 91 | ||
| @@ -136,12 +142,12 @@ func TestSchedulePendingGPUForcesRetry(t *testing.T) { | |||
| 136 | 142 | func TestScheduleErrorsPropagate(t *testing.T) { | |
| 137 | 143 | h := newHarness(t) | |
| 138 | 144 | 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 { | |
| 145 | + | if _, err := h.s.Step(true, func() (render.Frame, bool, error) { return render.Frame{}, false, h.drawErr }, nil); !errors.Is(err, h.drawErr) { | |
| 140 | 146 | t.Fatalf("draw err = %v", err) | |
| 141 | 147 | } | |
| 142 | 148 | h.drawErr = nil | |
| 143 | 149 | 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 { | |
| 150 | + | 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 || !errors.Is(err, h.submitErr) { | |
| 145 | 151 | t.Fatalf("submit error: ok=%v err=%v", ok, err) | |
| 146 | 152 | } | |
| 147 | 153 | } | |
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "testing" | 7 | "testing" | ||
| 8 | 8 | ||||
| 9 | - | "github.com/bnema/nefergui/internal/platform/wayland" | |||
| 10 | "github.com/bnema/nefergui/internal/render" | 9 | "github.com/bnema/nefergui/internal/render" | ||
| 11 | ) | 10 | ) | ||
| 12 | 11 | ||||
| @@ -61,25 +60,32 @@ func TestScheduleBlockedFrameKeepsPreparedAndRetries(t *testing.T) { | |||||
| 61 | } | 60 | } | ||
| 62 | } | 61 | } | ||
| 63 | 62 | ||||
| 64 | - | func TestScheduleInvalidateDropsPreparedFrame(t *testing.T) { | 63 | + | func TestScheduleInvalidateReplacesChangedFrame(t *testing.T) { |
| 65 | - | for _, name := range []string{"wake or input", "resize"} { | 64 | + | h := newHarness(t) |
| 66 | - | t.Run(name, func(t *testing.T) { | 65 | + | h.blocked = true |
| 67 | - | h := newHarness(t) | 66 | + | h.step() |
| 68 | - | h.blocked = true | 67 | + | h.s.Invalidate() |
| 69 | - | h.step() | 68 | + | h.blocked = false |
| 70 | - | if name == "resize" { | 69 | + | if !h.step() || h.draws != 2 || h.submits[len(h.submits)-1] != 2 { |
| 71 | - | // RunApp invalidates at the top of each iteration while Window.Dirty is set. | 70 | + | t.Fatalf("stale frame submitted: draws=%d submits=%v", h.draws, h.submits) |
| 72 | - | w := &wayland.Window{} | 71 | + | } |
| 73 | - | if err := w.Apply(wayland.Event{Kind: wayland.ConfigureSize, Width: 640, Height: 480}); err != nil || !w.Dirty { | 72 | + | } |
| 74 | - | t.Fatalf("configure did not dirty window: %v", err) | 73 | + | |
| 75 | - | } | 74 | + | func TestScheduleNoOpInputKeepsUnsubmittedFrame(t *testing.T) { |
| 76 | - | } | 75 | + | h := newHarness(t) |
| 77 | - | h.s.Invalidate() | 76 | + | h.blocked = true |
| 78 | - | h.blocked = false | 77 | + | h.step() |
| 79 | - | if !h.step() || h.draws != 2 || h.submits[len(h.submits)-1] != 2 { | 78 | + | h.s.Invalidate() |
| 80 | - | t.Fatalf("stale frame submitted: draws=%d submits=%v", h.draws, h.submits) | 79 | + | h.changed = false |
| 81 | - | } | 80 | + | if h.step() || h.draws != 2 || h.submits[len(h.submits)-1] != 1 { |
| 82 | - | }) | 81 | + | t.Fatalf("no-op lost held frame: draws=%d submits=%v", h.draws, h.submits) |
| 82 | + | } | |||
| 83 | + | h.blocked = false | |||
| 84 | + | if !h.step() || h.draws != 2 || h.submits[len(h.submits)-1] != 1 { | |||
| 85 | + | t.Fatalf("release lost held frame: draws=%d submits=%v", h.draws, h.submits) | |||
| 86 | + | } | |||
| 87 | + | if h.s.NeedsRetry(false, true) { | |||
| 88 | + | t.Fatal("committed schedule did not go idle") | |||
| 83 | } | 89 | } | ||
| 84 | } | 90 | } | ||
| 85 | 91 | ||||
| @@ -136,12 +142,12 @@ func TestSchedulePendingGPUForcesRetry(t *testing.T) { | |||||
| 136 | func TestScheduleErrorsPropagate(t *testing.T) { | 142 | func TestScheduleErrorsPropagate(t *testing.T) { | ||
| 137 | h := newHarness(t) | 143 | h := newHarness(t) | ||
| 138 | h.drawErr = errors.New("draw") | 144 | 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 { | 145 | + | if _, err := h.s.Step(true, func() (render.Frame, bool, error) { return render.Frame{}, false, h.drawErr }, nil); !errors.Is(err, h.drawErr) { |
| 140 | t.Fatalf("draw err = %v", err) | 146 | t.Fatalf("draw err = %v", err) | ||
| 141 | } | 147 | } | ||
| 142 | h.drawErr = nil | 148 | h.drawErr = nil | ||
| 143 | h.submitErr = errors.New("submit") | 149 | 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 { | 150 | + | 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 || !errors.Is(err, h.submitErr) { |
| 145 | t.Fatalf("submit error: ok=%v err=%v", ok, err) | 151 | t.Fatalf("submit error: ok=%v err=%v", ok, err) | ||
| 146 | } | 152 | } | ||
| 147 | } | 153 | } | ||