detached 7b0921e

fix(renderer): address review findings

commit
7b0921e423004c442a5d82806d43f8b7b9946c14
author
brice · · 2026-08-02 10:54 UTC
parent
15846e6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +93 -105
M pkg/renderer/benchmark_test.go +56 -62
@@ -82,69 +82,63 @@ func BenchmarkRendererFragmentedDamage(b *testing.B) {
8282 b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
8383 }
8484
85-func BenchmarkRendererIncrementalOneCellPrepareCommit(b *testing.B) {
86- frame := NewFrame(120, 40)
87- markBenchmarkFrame(&frame)
88- r := New(Capabilities{})
89- initial, err := r.Prepare(frame, []Damage{FullRedraw()}, false)
90- if err != nil {
91- b.Fatal(err)
92- }
93- initial.Commit()
94- damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}}
95-
96- b.ReportAllocs()
97- var outBytes int64
98- changed := false
99- for b.Loop() {
100- changed = !changed
101- cell := Cell{Rune: 'X', Style: DefaultStyle()}
102- if changed {
103- cell.Rune = 'Y'
104- }
105- frame.Set(60, 20, cell)
106- prepared, err := r.Prepare(frame, damage, false)
107- if err != nil {
108- b.Fatal(err)
109- }
110- if len(prepared.Bytes()) == 0 {
111- b.Fatal("expected renderer output")
112- }
113- outBytes += int64(len(prepared.Bytes()))
114- prepared.Commit()
115- }
116- b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
117-}
118-
119-func BenchmarkRendererIncrementalOneCellDraw(b *testing.B) {
120- frame := NewFrame(120, 40)
121- markBenchmarkFrame(&frame)
122- r := New(Capabilities{})
123- if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
124- b.Fatal(err)
125- }
126- damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}}
127-
128- b.ReportAllocs()
129- var outBytes int64
130- changed := false
131- for b.Loop() {
132- changed = !changed
133- cell := Cell{Rune: 'X', Style: DefaultStyle()}
134- if changed {
135- cell.Rune = 'Y'
136- }
137- frame.Set(60, 20, cell)
138- out, err := r.Draw(frame, damage)
139- if err != nil {
140- b.Fatal(err)
141- }
142- if len(out) == 0 {
143- b.Fatal("expected renderer output")
144- }
145- outBytes += int64(len(out))
85+func BenchmarkRendererIncrementalOneCell(b *testing.B) {
86+ for _, tt := range []struct {
87+ name string
88+ prepareCommit bool
89+ }{
90+ {name: "PrepareCommit", prepareCommit: true},
91+ {name: "Draw"},
92+ } {
93+ b.Run(tt.name, func(b *testing.B) {
94+ frame := NewFrame(120, 40)
95+ markBenchmarkFrame(&frame)
96+ r := New(Capabilities{})
97+ if tt.prepareCommit {
98+ initial, err := r.Prepare(frame, []Damage{FullRedraw()}, false)
99+ if err != nil {
100+ b.Fatal(err)
101+ }
102+ initial.Commit()
103+ } else if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
104+ b.Fatal(err)
105+ }
106+ damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}}
107+
108+ b.ReportAllocs()
109+ var outBytes int64
110+ changed := false
111+ for b.Loop() {
112+ changed = !changed
113+ cell := Cell{Rune: 'X', Style: DefaultStyle()}
114+ if changed {
115+ cell.Rune = 'Y'
116+ }
117+ frame.Set(60, 20, cell)
118+ if tt.prepareCommit {
119+ prepared, err := r.Prepare(frame, damage, false)
120+ if err != nil {
121+ b.Fatal(err)
122+ }
123+ if len(prepared.Bytes()) == 0 {
124+ b.Fatal("expected renderer output")
125+ }
126+ outBytes += int64(len(prepared.Bytes()))
127+ prepared.Commit()
128+ continue
129+ }
130+ out, err := r.Draw(frame, damage)
131+ if err != nil {
132+ b.Fatal(err)
133+ }
134+ if len(out) == 0 {
135+ b.Fatal("expected renderer output")
136+ }
137+ outBytes += int64(len(out))
138+ }
139+ b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
140+ })
146141 }
147- b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
148142 }
149143
150144 func BenchmarkRendererIncrementalNoBytePrepareCommit(b *testing.B) {
@@ -82,69 +82,63 @@ func BenchmarkRendererFragmentedDamage(b *testing.B) {
82 b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")82 b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
83 }83 }
84 84
85-func BenchmarkRendererIncrementalOneCellPrepareCommit(b *testing.B) {85+func BenchmarkRendererIncrementalOneCell(b *testing.B) {
86- frame := NewFrame(120, 40)86+ for _, tt := range []struct {
87- markBenchmarkFrame(&frame)87+ name string
88- r := New(Capabilities{})88+ prepareCommit bool
89- initial, err := r.Prepare(frame, []Damage{FullRedraw()}, false)89+ }{
90- if err != nil {90+ {name: "PrepareCommit", prepareCommit: true},
91- b.Fatal(err)91+ {name: "Draw"},
92- }92+ } {
93- initial.Commit()93+ b.Run(tt.name, func(b *testing.B) {
94- damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}}94+ frame := NewFrame(120, 40)
95-95+ markBenchmarkFrame(&frame)
96- b.ReportAllocs()96+ r := New(Capabilities{})
97- var outBytes int6497+ if tt.prepareCommit {
98- changed := false98+ initial, err := r.Prepare(frame, []Damage{FullRedraw()}, false)
99- for b.Loop() {99+ if err != nil {
100- changed = !changed100+ b.Fatal(err)
101- cell := Cell{Rune: 'X', Style: DefaultStyle()}101+ }
102- if changed {102+ initial.Commit()
103- cell.Rune = 'Y'103+ } else if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
104- }104+ b.Fatal(err)
105- frame.Set(60, 20, cell)105+ }
106- prepared, err := r.Prepare(frame, damage, false)106+ damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}}
107- if err != nil {107+
108- b.Fatal(err)108+ b.ReportAllocs()
109- }109+ var outBytes int64
110- if len(prepared.Bytes()) == 0 {110+ changed := false
111- b.Fatal("expected renderer output")111+ for b.Loop() {
112- }112+ changed = !changed
113- outBytes += int64(len(prepared.Bytes()))113+ cell := Cell{Rune: 'X', Style: DefaultStyle()}
114- prepared.Commit()114+ if changed {
115- }115+ cell.Rune = 'Y'
116- b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")116+ }
117-}117+ frame.Set(60, 20, cell)
118-118+ if tt.prepareCommit {
119-func BenchmarkRendererIncrementalOneCellDraw(b *testing.B) {119+ prepared, err := r.Prepare(frame, damage, false)
120- frame := NewFrame(120, 40)120+ if err != nil {
121- markBenchmarkFrame(&frame)121+ b.Fatal(err)
122- r := New(Capabilities{})122+ }
123- if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {123+ if len(prepared.Bytes()) == 0 {
124- b.Fatal(err)124+ b.Fatal("expected renderer output")
125- }125+ }
126- damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}}126+ outBytes += int64(len(prepared.Bytes()))
127-127+ prepared.Commit()
128- b.ReportAllocs()128+ continue
129- var outBytes int64129+ }
130- changed := false130+ out, err := r.Draw(frame, damage)
131- for b.Loop() {131+ if err != nil {
132- changed = !changed132+ b.Fatal(err)
133- cell := Cell{Rune: 'X', Style: DefaultStyle()}133+ }
134- if changed {134+ if len(out) == 0 {
135- cell.Rune = 'Y'135+ b.Fatal("expected renderer output")
136- }136+ }
137- frame.Set(60, 20, cell)137+ outBytes += int64(len(out))
138- out, err := r.Draw(frame, damage)138+ }
139- if err != nil {139+ b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
140- b.Fatal(err)140+ })
141- }
142- if len(out) == 0 {
143- b.Fatal("expected renderer output")
144- }
145- outBytes += int64(len(out))
146 }141 }
147- b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
148 }142 }
149 143
150 func BenchmarkRendererIncrementalNoBytePrepareCommit(b *testing.B) {144 func BenchmarkRendererIncrementalNoBytePrepareCommit(b *testing.B) {
M pkg/renderer/damage_plan.go +16 -12
@@ -19,18 +19,7 @@ func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool)
1919 if d.Kind != DamageText && d.Kind != DamageClear {
2020 return nil, false
2121 }
22- x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)
23- if !ok {
24- return nil, false
25- }
26- if height > maxPlannedDamageSpans {
27- return nil, true
28- }
29- spans := make([]Span, height)
30- for row := range height {
31- spans[row] = Span{Y: y + row, X: x, Width: width}
32- }
33- return spans, false
22+ return buildSingleDamageSpans(frame, d)
3423 }
3524
3625 spans := make([]Span, 0)
@@ -63,6 +52,21 @@ func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool)
6352 return mergeDamageSpans(spans), false
6453 }
6554
55+func buildSingleDamageSpans(frame Frame, d Damage) ([]Span, bool) {
56+ x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)
57+ if !ok {
58+ return nil, false
59+ }
60+ if height > maxPlannedDamageSpans {
61+ return nil, true
62+ }
63+ spans := make([]Span, height)
64+ for row := range height {
65+ spans[row] = Span{Y: y + row, X: x, Width: width}
66+ }
67+ return spans, false
68+}
69+
6670 func mergeDamageSpans(spans []Span) []Span {
6771 if len(spans) < 2 {
6872 return spans
@@ -19,18 +19,7 @@ func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool)
19 if d.Kind != DamageText && d.Kind != DamageClear {19 if d.Kind != DamageText && d.Kind != DamageClear {
20 return nil, false20 return nil, false
21 }21 }
22- x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)22+ return buildSingleDamageSpans(frame, d)
23- if !ok {
24- return nil, false
25- }
26- if height > maxPlannedDamageSpans {
27- return nil, true
28- }
29- spans := make([]Span, height)
30- for row := range height {
31- spans[row] = Span{Y: y + row, X: x, Width: width}
32- }
33- return spans, false
34 }23 }
35 24
36 spans := make([]Span, 0)25 spans := make([]Span, 0)
@@ -63,6 +52,21 @@ func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool)
63 return mergeDamageSpans(spans), false52 return mergeDamageSpans(spans), false
64 }53 }
65 54
55+func buildSingleDamageSpans(frame Frame, d Damage) ([]Span, bool) {
56+ x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)
57+ if !ok {
58+ return nil, false
59+ }
60+ if height > maxPlannedDamageSpans {
61+ return nil, true
62+ }
63+ spans := make([]Span, height)
64+ for row := range height {
65+ spans[row] = Span{Y: y + row, X: x, Width: width}
66+ }
67+ return spans, false
68+}
69+
66 func mergeDamageSpans(spans []Span) []Span {70 func mergeDamageSpans(spans []Span) []Span {
67 if len(spans) < 2 {71 if len(spans) < 2 {
68 return spans72 return spans
M pkg/renderer/delta_plan.go +3 -10
@@ -89,19 +89,12 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta
8989 }
9090
9191 func planSingleDamage(frame Frame, d Damage) DeltaPlan {
92- x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)
93- if !ok {
94- return DeltaPlan{}
95- }
96- if height > maxPlannedDamageSpans {
92+ spans, full := buildSingleDamageSpans(frame, d)
93+ if full {
9794 return DeltaPlan{Snapshot: true}
9895 }
99- spans := make([]Span, height)
100- for row := range height {
101- spans[row] = Span{Y: y + row, X: x, Width: width}
102- }
10396 plan := DeltaPlan{Spans: spans}
104- if (frame.Height == 1 || len(spans) > 1) && deltaCostsSnapshot(frame, spans, false) {
97+ if deltaCostsSnapshot(frame, spans, false) {
10598 plan.Snapshot = true
10699 }
107100 return plan
@@ -89,19 +89,12 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta
89 }89 }
90 90
91 func planSingleDamage(frame Frame, d Damage) DeltaPlan {91 func planSingleDamage(frame Frame, d Damage) DeltaPlan {
92- x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)92+ spans, full := buildSingleDamageSpans(frame, d)
93- if !ok {93+ if full {
94- return DeltaPlan{}
95- }
96- if height > maxPlannedDamageSpans {
97 return DeltaPlan{Snapshot: true}94 return DeltaPlan{Snapshot: true}
98 }95 }
99- spans := make([]Span, height)
100- for row := range height {
101- spans[row] = Span{Y: y + row, X: x, Width: width}
102- }
103 plan := DeltaPlan{Spans: spans}96 plan := DeltaPlan{Spans: spans}
104- if (frame.Height == 1 || len(spans) > 1) && deltaCostsSnapshot(frame, spans, false) {97+ if deltaCostsSnapshot(frame, spans, false) {
105 plan.Snapshot = true98 plan.Snapshot = true
106 }99 }
107 return plan100 return plan
M pkg/renderer/delta_plan_test.go +10 -0
@@ -85,6 +85,16 @@ func TestPlanDelta(t *testing.T) {
8585 Spans: []Span{{Y: 6, X: 0, Width: 4}},
8686 },
8787 },
88+ {
89+ name: "safe full width scroll with expensive delta snapshots",
90+ frame: testFrame("1111", "new!"),
91+ damage: []Damage{
92+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 2, Count: 1},
93+ {Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2},
94+ },
95+ committed: testFrame("0000", "1111"),
96+ want: DeltaPlan{Snapshot: true},
97+ },
8898 {
8999 name: "rectangular scroll snapshots",
90100 frame: scrolled,
@@ -85,6 +85,16 @@ func TestPlanDelta(t *testing.T) {
85 Spans: []Span{{Y: 6, X: 0, Width: 4}},85 Spans: []Span{{Y: 6, X: 0, Width: 4}},
86 },86 },
87 },87 },
88+ {
89+ name: "safe full width scroll with expensive delta snapshots",
90+ frame: testFrame("1111", "new!"),
91+ damage: []Damage{
92+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 2, Count: 1},
93+ {Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2},
94+ },
95+ committed: testFrame("0000", "1111"),
96+ want: DeltaPlan{Snapshot: true},
97+ },
88 {98 {
89 name: "rectangular scroll snapshots",99 name: "rectangular scroll snapshots",
90 frame: scrolled,100 frame: scrolled,
M pkg/renderer/renderer.go +7 -20
@@ -59,30 +59,14 @@ func (p *PreparedDraw) Commit() {
5959 }
6060
6161 // Prepare plans and encodes a transactional draw. The renderer advances only
62-// when the returned draw is committed.
62+// when the returned draw is committed. Keep at most one prepared draw
63+// outstanding; commit or discard it before calling Prepare again.
6364 func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDraw, error) {
6465 var candidate DeltaCandidate
6566 var err error
6667 if !reset && len(r.shadow) != 0 && r.width == frame.Width && r.height == frame.Height && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
6768 if err = frame.Validate(); err == nil {
68- d := damage[0]
69- x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)
70- plan := DeltaPlan{}
71- if ok {
72- if height > maxPlannedDamageSpans {
73- plan.Snapshot = true
74- } else {
75- spans := make([]Span, height)
76- for row := range height {
77- spans[row] = Span{Y: y + row, X: x, Width: width}
78- }
79- if (frame.Height == 1 || len(spans) > 1) && deltaCostsSnapshot(frame, spans, false) {
80- plan.Snapshot = true
81- }
82- plan.Spans = spans
83- }
84- }
85- candidate = DeltaCandidate{Plan: plan, frame: frame}
69+ candidate = DeltaCandidate{Plan: planSingleDamage(frame, damage[0]), frame: frame}
8670 }
8771 } else {
8872 candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || len(r.shadow) == 0)
@@ -96,7 +80,10 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr
9680 return prepared, nil
9781 }
9882
99- buf := bufferPool.Get().(*bytes.Buffer)
83+ buf, ok := bufferPool.Get().(*bytes.Buffer)
84+ if !ok {
85+ buf = new(bytes.Buffer)
86+ }
10087 buf.Reset()
10188 defer putBuffer(buf)
10289 if r.caps.SynchronizedOutput {
@@ -59,30 +59,14 @@ func (p *PreparedDraw) Commit() {
59 }59 }
60 60
61 // Prepare plans and encodes a transactional draw. The renderer advances only61 // Prepare plans and encodes a transactional draw. The renderer advances only
62-// when the returned draw is committed.62+// when the returned draw is committed. Keep at most one prepared draw
63+// outstanding; commit or discard it before calling Prepare again.
63 func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDraw, error) {64 func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDraw, error) {
64 var candidate DeltaCandidate65 var candidate DeltaCandidate
65 var err error66 var err error
66 if !reset && len(r.shadow) != 0 && r.width == frame.Width && r.height == frame.Height && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {67 if !reset && len(r.shadow) != 0 && r.width == frame.Width && r.height == frame.Height && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
67 if err = frame.Validate(); err == nil {68 if err = frame.Validate(); err == nil {
68- d := damage[0]69+ candidate = DeltaCandidate{Plan: planSingleDamage(frame, damage[0]), frame: frame}
69- x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)
70- plan := DeltaPlan{}
71- if ok {
72- if height > maxPlannedDamageSpans {
73- plan.Snapshot = true
74- } else {
75- spans := make([]Span, height)
76- for row := range height {
77- spans[row] = Span{Y: y + row, X: x, Width: width}
78- }
79- if (frame.Height == 1 || len(spans) > 1) && deltaCostsSnapshot(frame, spans, false) {
80- plan.Snapshot = true
81- }
82- plan.Spans = spans
83- }
84- }
85- candidate = DeltaCandidate{Plan: plan, frame: frame}
86 }70 }
87 } else {71 } else {
88 candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || len(r.shadow) == 0)72 candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || len(r.shadow) == 0)
@@ -96,7 +80,10 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr
96 return prepared, nil80 return prepared, nil
97 }81 }
98 82
99- buf := bufferPool.Get().(*bytes.Buffer)83+ buf, ok := bufferPool.Get().(*bytes.Buffer)
84+ if !ok {
85+ buf = new(bytes.Buffer)
86+ }
100 buf.Reset()87 buf.Reset()
101 defer putBuffer(buf)88 defer putBuffer(buf)
102 if r.caps.SynchronizedOutput {89 if r.caps.SynchronizedOutput {
M pkg/renderer/scroll.go +1 -1
@@ -15,7 +15,7 @@ func findSafeScroll(frame Frame, damage []Damage) (Damage, bool) {
1515 }
1616
1717 func isSafeScroll(frame Frame, d Damage) bool {
18- return d.X == 0 && d.Width == frame.Width && d.Y >= 0 && d.Height > 0 && d.Count > 0 && d.Count < d.Height && d.Y+d.Height <= frame.Height
18+ return d.X == 0 && d.Width == frame.Width && d.Y >= 0 && d.Y <= frame.Height && d.Height > 0 && d.Height <= frame.Height-d.Y && d.Count > 0 && d.Count < d.Height
1919 }
2020
2121 func emitScrollUp(out *bytes.Buffer, d Damage) {
@@ -15,7 +15,7 @@ func findSafeScroll(frame Frame, damage []Damage) (Damage, bool) {
15 }15 }
16 16
17 func isSafeScroll(frame Frame, d Damage) bool {17 func isSafeScroll(frame Frame, d Damage) bool {
18- return d.X == 0 && d.Width == frame.Width && d.Y >= 0 && d.Height > 0 && d.Count > 0 && d.Count < d.Height && d.Y+d.Height <= frame.Height18+ return d.X == 0 && d.Width == frame.Width && d.Y >= 0 && d.Y <= frame.Height && d.Height > 0 && d.Height <= frame.Height-d.Y && d.Count > 0 && d.Count < d.Height
19 }19 }
20 20
21 func emitScrollUp(out *bytes.Buffer, d Damage) {21 func emitScrollUp(out *bytes.Buffer, d Damage) {

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 12

Tags 11

↑↓ moveenter openesc close