detached d438c5c

fix(gui): retain blocked frames across no-op input

commit
d438c5c2824bd8505600c6843b199971f1c9cf6d
author
brice · · 2026-09-30 03:31 UTC
parent
701c8cc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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,
5454 lines := geometry.lines
5555 var result []layout.Command
5656 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)
6158 for j := 0; j+1 < len(line); j += 2 {
6259 a, b := line[j], line[j+1]
6360 if a.byteOffset == b.byteOffset {
@@ -77,8 +74,8 @@ func editorDecoration(s *edit.State, n *layout.Result, display []layout.Command,
7774 }
7875 }
7976 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.
8279 point, found := geometry.caret(s.Cursor)
8380 if !found && len(lines) == 0 {
8481 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.lines54 lines := geometry.lines
55 var result []layout.Command55 var result []layout.Command
56 for i, line := range lines {56 for i, line := range lines {
57- h := 0.057+ 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 trailing77+ // 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
1515 // frameSchedule is RunApp's frame state, touched only from the owner loop.
1616 // A frame is wanted (dirty) until the compositor accepted a commit or the app
1717 // 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.
2020 type frameSchedule struct {
2121 dirty bool
2222 prepared render.Frame
2323 have bool
24+ stale bool
2425 }
2526
2627 // newFrameSchedule starts dirty so the first configured frame is drawn.
2728 func newFrameSchedule() *frameSchedule { return &frameSchedule{dirty: true} }
2829
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.
3132 func (f *frameSchedule) Invalidate() {
3233 f.dirty = true
33- f.have = false
34- f.prepared = render.Frame{}
34+ f.stale = true
3535 }
3636
3737 // 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
4041 // committed: on false the prepared frame is kept for the next attempt, on true
4142 // the schedule is clean until the next Invalidate.
4243 func (f *frameSchedule) Step(
@@ -47,22 +48,24 @@ func (f *frameSchedule) Step(
4748 if !f.dirty || !ready {
4849 return false, nil
4950 }
50- if !f.have {
51+ if !f.have || f.stale {
5152 frame, changed, err := draw()
5253 if err != nil {
5354 return false, err
5455 }
55- if !changed {
56+ f.stale = false
57+ if changed {
58+ f.prepared, f.have = frame, true
59+ } else if !f.have {
5660 f.dirty = false
5761 return false, nil
5862 }
59- f.prepared, f.have = frame, true
6063 }
6164 committed, err = submit(f.prepared)
6265 if err != nil || !committed {
6366 return false, err
6467 }
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{}
6669 return true, nil
6770 }
6871
@@ -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 app16 // 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 so17 // 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, a18+// 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 bool21 dirty bool
22 prepared render.Frame22 prepared render.Frame
23 have bool23 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 never30+// 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 = true33 f.dirty = true
33- f.have = false34+ 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 the37 // 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 was39+// 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 true41 // 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, nil49 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, err54 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 = false60 f.dirty = false
57 return false, nil61 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, err66 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, nil69 return true, nil
67 }70 }
68 71
M internal/presentation/session/schedule_test.go +28 -22
@@ -6,7 +6,6 @@ import (
66 "errors"
77 "testing"
88
9- "github.com/bnema/nefergui/internal/platform/wayland"
109 "github.com/bnema/nefergui/internal/render"
1110 )
1211
@@ -61,25 +60,32 @@ func TestScheduleBlockedFrameKeepsPreparedAndRetries(t *testing.T) {
6160 }
6261 }
6362
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")
8389 }
8490 }
8591
@@ -136,12 +142,12 @@ func TestSchedulePendingGPUForcesRetry(t *testing.T) {
136142 func TestScheduleErrorsPropagate(t *testing.T) {
137143 h := newHarness(t)
138144 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) {
140146 t.Fatalf("draw err = %v", err)
141147 }
142148 h.drawErr = nil
143149 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) {
145151 t.Fatalf("submit error: ok=%v err=%v", ok, err)
146152 }
147153 }
@@ -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 = true67+ 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 = false77+ 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 = nil148 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 }

Keyboard shortcuts

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

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

Branches 1

Tags 12

↑↓ moveenter openesc close