detached a46389e

fix(scene): carry an animating window's content scale as Zoom, apart from Preview

publish copied the derived zoom into SceneWindow.Preview, which the
vulkan walk reads as "overview card" (opensFloats): an appearing or
leaving float stopped opening the floats, so the veil went missing and
popped back and the tile lines were painted over the fading float.

SceneWindow.Zoom (15 fields) is the content scale of a window whose frame
animates; the walk uses it over Preview for content only, opensFloats
keeps reading Preview. DRM scanout and the overlay plane refuse Zoom > 0
like Preview. sceneDelta covers it by struct equality (the zoomed content
stays inside Rect). Walk test: a zoomed float with scene.Dim and
separators paints the veil and the tile lines under it exactly as
unzoomed; a Preview card still does not.
commit
a46389e57a40eb9e5f73b1cebcb7dc246dfcf420
author
brice · · 2026-10-04 22:34 UTC
parent
8ecc5f8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +78 -6
M internal/adapters/drm/overlay.go +1 -1
@@ -85,7 +85,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
8585 // window (a peeking stashed one) needs the veil drawn over it, a
8686 // fading one its translucency, and one with the focus effect its
8787 // lift; an overview preview is drawn smaller than its buffer.
88- if w.Dim <= 0 && w.Fade <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) {
88+ if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) {
8989 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {
9090 pick, mode = w, m
9191 continue
@@ -85,7 +85,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
85 // window (a peeking stashed one) needs the veil drawn over it, a85 // window (a peeking stashed one) needs the veil drawn over it, a
86 // fading one its translucency, and one with the focus effect its86 // fading one its translucency, and one with the focus effect its
87 // lift; an overview preview is drawn smaller than its buffer.87 // lift; an overview preview is drawn smaller than its buffer.
88- if w.Dim <= 0 && w.Fade <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) {88+ if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) {
89 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {89 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {
90 pick, mode = w, m90 pick, mode = w, m
91 continue91 continue
M internal/adapters/drm/overlay_test.go +6 -0
@@ -119,6 +119,12 @@ func TestOverlayCandidate(t *testing.T) {
119119 if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
120120 t.Fatalf("fading window reason %q", reason)
121121 }
122+ // A zoomed (animating) window is drawn smaller than its buffer.
123+ s, _ = overlayScene()
124+ s.Windows[len(s.Windows)-1].Zoom = 0.9
125+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
126+ t.Fatalf("zoomed window reason %q", reason)
127+ }
122128 // A window with the focus effect needs it composed.
123129 s, _ = overlayScene()
124130 s.Windows[len(s.Windows)-1].FocusEffect = 0.05
@@ -119,6 +119,12 @@ func TestOverlayCandidate(t *testing.T) {
119 if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {119 if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
120 t.Fatalf("fading window reason %q", reason)120 t.Fatalf("fading window reason %q", reason)
121 }121 }
122+ // A zoomed (animating) window is drawn smaller than its buffer.
123+ s, _ = overlayScene()
124+ s.Windows[len(s.Windows)-1].Zoom = 0.9
125+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
126+ t.Fatalf("zoomed window reason %q", reason)
127+ }
122 // A window with the focus effect needs it composed.128 // A window with the focus effect needs it composed.
123 s, _ = overlayScene()129 s, _ = overlayScene()
124 s.Windows[len(s.Windows)-1].FocusEffect = 0.05130 s.Windows[len(s.Windows)-1].FocusEffect = 0.05
M internal/adapters/drm/scanout.go +1 -1
@@ -115,7 +115,7 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
115115 // Its veil is composed over it: the plane would show it bright.
116116 return ports.SurfaceContent{}, "dim"
117117 }
118- if full.Fade > 0 || full.Preview > 0 {
118+ if full.Fade > 0 || full.Preview > 0 || full.Zoom > 0 {
119119 // A fading or zoomed window is composed: the plane would show it
120120 // whole and opaque.
121121 return ports.SurfaceContent{}, "window_fade"
@@ -115,7 +115,7 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
115 // Its veil is composed over it: the plane would show it bright.115 // Its veil is composed over it: the plane would show it bright.
116 return ports.SurfaceContent{}, "dim"116 return ports.SurfaceContent{}, "dim"
117 }117 }
118- if full.Fade > 0 || full.Preview > 0 {118+ if full.Fade > 0 || full.Preview > 0 || full.Zoom > 0 {
119 // A fading or zoomed window is composed: the plane would show it119 // A fading or zoomed window is composed: the plane would show it
120 // whole and opaque.120 // whole and opaque.
121 return ports.SurfaceContent{}, "window_fade"121 return ports.SurfaceContent{}, "window_fade"
M internal/adapters/drm/scanout_test.go +2 -1
@@ -28,7 +28,8 @@ func TestScanoutCandidate(t *testing.T) {
2828 {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"},
2929 {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"},
3030 {"window fade", func(s *ports.Scene) { s.Windows[0].Fade = 0.5 }, nil, "window_fade"},
31- {"window zoom", func(s *ports.Scene) { s.Windows[0].Preview = 0.9 }, nil, "window_fade"},
31+ {"window preview", func(s *ports.Scene) { s.Windows[0].Preview = 0.9 }, nil, "window_fade"},
32+ {"window zoom", func(s *ports.Scene) { s.Windows[0].Zoom = 0.9 }, nil, "window_fade"},
3233 {"faded", nil, func(c *ports.SurfaceContent) { c.Fade = 0.5 }, "fade"},
3334 {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"},
3435 {"float above", func(s *ports.Scene) {
@@ -28,7 +28,8 @@ func TestScanoutCandidate(t *testing.T) {
28 {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"},28 {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"},
29 {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"},29 {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"},
30 {"window fade", func(s *ports.Scene) { s.Windows[0].Fade = 0.5 }, nil, "window_fade"},30 {"window fade", func(s *ports.Scene) { s.Windows[0].Fade = 0.5 }, nil, "window_fade"},
31- {"window zoom", func(s *ports.Scene) { s.Windows[0].Preview = 0.9 }, nil, "window_fade"},31+ {"window preview", func(s *ports.Scene) { s.Windows[0].Preview = 0.9 }, nil, "window_fade"},
32+ {"window zoom", func(s *ports.Scene) { s.Windows[0].Zoom = 0.9 }, nil, "window_fade"},
32 {"faded", nil, func(c *ports.SurfaceContent) { c.Fade = 0.5 }, "fade"},33 {"faded", nil, func(c *ports.SurfaceContent) { c.Fade = 0.5 }, "fade"},
33 {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"},34 {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"},
34 {"float above", func(s *ports.Scene) {35 {"float above", func(s *ports.Scene) {
M internal/adapters/vulkan/damage_delta_test.go +1 -1
@@ -26,7 +26,7 @@ func TestSceneFieldCount(t *testing.T) {
2626 // sceneDelta compares windows by struct equality, so a new field is covered
2727 // as long as it only changes where or how that window draws (inside Rect).
2828 func TestSceneWindowFieldCount(t *testing.T) {
29- const fields = 14
29+ const fields = 15
3030 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),
3131 "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")
3232 }
@@ -26,7 +26,7 @@ func TestSceneFieldCount(t *testing.T) {
26 // sceneDelta compares windows by struct equality, so a new field is covered26 // sceneDelta compares windows by struct equality, so a new field is covered
27 // as long as it only changes where or how that window draws (inside Rect).27 // as long as it only changes where or how that window draws (inside Rect).
28 func TestSceneWindowFieldCount(t *testing.T) {28 func TestSceneWindowFieldCount(t *testing.T) {
29- const fields = 1429+ const fields = 15
30 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),30 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),
31 "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")31 "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")
32 }32 }
M internal/adapters/vulkan/scene.go +5 -1
@@ -231,7 +231,11 @@ func (w *sceneWalk) windows() {
231231 // Until its first buffer, a window shows the background: no flash.
232232 w.fill(body, parseColor(w.s.Background))
233233 if !content.Empty() {
234- if win.Preview > 0 {
234+ // An animating frame zooms its content (Zoom); a card draws
235+ // at its Preview.
236+ if win.Zoom > 0 {
237+ w.zoom = win.Zoom
238+ } else if win.Preview > 0 {
235239 w.zoom = win.Preview
236240 }
237241 w.pulse = float32(max(0, min(win.FocusEffect, 1)))
@@ -231,7 +231,11 @@ func (w *sceneWalk) windows() {
231 // Until its first buffer, a window shows the background: no flash.231 // Until its first buffer, a window shows the background: no flash.
232 w.fill(body, parseColor(w.s.Background))232 w.fill(body, parseColor(w.s.Background))
233 if !content.Empty() {233 if !content.Empty() {
234- if win.Preview > 0 {234+ // An animating frame zooms its content (Zoom); a card draws
235+ // at its Preview.
236+ if win.Zoom > 0 {
237+ w.zoom = win.Zoom
238+ } else if win.Preview > 0 {
235 w.zoom = win.Preview239 w.zoom = win.Preview
236 }240 }
237 w.pulse = float32(max(0, min(win.FocusEffect, 1)))241 w.pulse = float32(max(0, min(win.FocusEffect, 1)))
M internal/adapters/vulkan/scene_fade_test.go +55 -0
@@ -86,3 +86,58 @@ func TestSceneWindowFadeHDR(t *testing.T) {
8686 t.Fatalf("hdr faded white = %v, want rgb %v alpha 0.5", c, want)
8787 }
8888 }
89+
90+// An appearing or leaving float carries a Zoom, not a Preview: it still
91+// opens the floats, so the tile lines and the veil are painted under it,
92+// exactly as for the same float unzoomed; only its content draw shrinks.
93+func TestSceneZoomedFloatOpensFloats(t *testing.T) {
94+ r := &Renderer{width: 80, height: 60}
95+ scene := func(zoom float64) ports.Scene {
96+ return ports.Scene{
97+ Dim: 0.4, Background: "#000000", Border: ports.Border{Width: 1, Active: "#ffffff", Inactive: "#808080"},
98+ Windows: []ports.SceneWindow{
99+ {ID: 1, Rect: ports.Rect{W: 80, H: 60}},
100+ {ID: 2, Floating: true, Rect: ports.Rect{X: 20, Y: 10, W: 40, H: 40}, Inset: ports.SideAll, Fade: 0.5, Zoom: zoom},
101+ },
102+ Separators: []ports.Separator{
103+ {Rect: ports.Rect{X: 40, W: 1, H: 60}},
104+ {Rect: ports.Rect{X: 20, Y: 10, W: 1, H: 40}, Window: 2},
105+ },
106+ }
107+ }
108+ contents := map[ports.WindowID]ports.SurfaceContent{
109+ 2: {ID: 2, Width: 1, Height: 1, LogicalW: 38, LogicalH: 38, Solid: &ports.SolidColor{R: 1, G: 1, B: 1, A: 1}},
110+ }
111+ walk := func(s ports.Scene) []draw {
112+ return slices.Clone(r.draws(s, contents, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))))
113+ }
114+ plain, zoomed := walk(scene(0)), walk(scene(0.9))
115+ // tile fill, tile line, veil, float fill, float content, float line
116+ if len(plain) != 6 || len(zoomed) != 6 {
117+ t.Fatalf("draws: plain %d zoomed %d, want 6", len(plain), len(zoomed))
118+ }
119+ for i := range plain {
120+ if i == 4 {
121+ continue
122+ }
123+ if plain[i].pc != zoomed[i].pc {
124+ t.Fatalf("draw %d differs with the zoom: %+v vs %+v", i, plain[i].pc, zoomed[i].pc)
125+ }
126+ }
127+ if veil := zoomed[2].pc; veil.rect != [4]int32{0, 0, 80, 60} || veil.color != [4]float32{0, 0, 0, 0.4} {
128+ t.Fatalf("veil = %+v, want the full-output 0.4 dim under the float", veil)
129+ }
130+ if zoomed[1].pc.rect != [4]int32{40, 0, 41, 60} {
131+ t.Fatalf("tile line = %+v, want it painted before the float", zoomed[1].pc.rect)
132+ }
133+ pr, zr := plain[4].pc.rect, zoomed[4].pc.rect
134+ if !(zr[2]-zr[0] < pr[2]-pr[0]) || zr[0] != pr[0] || zr[1] != pr[1] {
135+ t.Fatalf("zoomed content %v, want smaller than %v from the same origin", zr, pr)
136+ }
137+ // A card (Preview) keeps not opening the floats: no tile lines, no veil.
138+ card := scene(0)
139+ card.Windows[1].Preview = 0.9
140+ if ds := walk(card); len(ds) != 5 || ds[1].pc.color[3] != 0.5 {
141+ t.Fatalf("card draws = %d, want 5 (lines after, no veil): %+v", len(ds), ds)
142+ }
143+}
@@ -86,3 +86,58 @@ func TestSceneWindowFadeHDR(t *testing.T) {
86 t.Fatalf("hdr faded white = %v, want rgb %v alpha 0.5", c, want)86 t.Fatalf("hdr faded white = %v, want rgb %v alpha 0.5", c, want)
87 }87 }
88 }88 }
89+
90+// An appearing or leaving float carries a Zoom, not a Preview: it still
91+// opens the floats, so the tile lines and the veil are painted under it,
92+// exactly as for the same float unzoomed; only its content draw shrinks.
93+func TestSceneZoomedFloatOpensFloats(t *testing.T) {
94+ r := &Renderer{width: 80, height: 60}
95+ scene := func(zoom float64) ports.Scene {
96+ return ports.Scene{
97+ Dim: 0.4, Background: "#000000", Border: ports.Border{Width: 1, Active: "#ffffff", Inactive: "#808080"},
98+ Windows: []ports.SceneWindow{
99+ {ID: 1, Rect: ports.Rect{W: 80, H: 60}},
100+ {ID: 2, Floating: true, Rect: ports.Rect{X: 20, Y: 10, W: 40, H: 40}, Inset: ports.SideAll, Fade: 0.5, Zoom: zoom},
101+ },
102+ Separators: []ports.Separator{
103+ {Rect: ports.Rect{X: 40, W: 1, H: 60}},
104+ {Rect: ports.Rect{X: 20, Y: 10, W: 1, H: 40}, Window: 2},
105+ },
106+ }
107+ }
108+ contents := map[ports.WindowID]ports.SurfaceContent{
109+ 2: {ID: 2, Width: 1, Height: 1, LogicalW: 38, LogicalH: 38, Solid: &ports.SolidColor{R: 1, G: 1, B: 1, A: 1}},
110+ }
111+ walk := func(s ports.Scene) []draw {
112+ return slices.Clone(r.draws(s, contents, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))))
113+ }
114+ plain, zoomed := walk(scene(0)), walk(scene(0.9))
115+ // tile fill, tile line, veil, float fill, float content, float line
116+ if len(plain) != 6 || len(zoomed) != 6 {
117+ t.Fatalf("draws: plain %d zoomed %d, want 6", len(plain), len(zoomed))
118+ }
119+ for i := range plain {
120+ if i == 4 {
121+ continue
122+ }
123+ if plain[i].pc != zoomed[i].pc {
124+ t.Fatalf("draw %d differs with the zoom: %+v vs %+v", i, plain[i].pc, zoomed[i].pc)
125+ }
126+ }
127+ if veil := zoomed[2].pc; veil.rect != [4]int32{0, 0, 80, 60} || veil.color != [4]float32{0, 0, 0, 0.4} {
128+ t.Fatalf("veil = %+v, want the full-output 0.4 dim under the float", veil)
129+ }
130+ if zoomed[1].pc.rect != [4]int32{40, 0, 41, 60} {
131+ t.Fatalf("tile line = %+v, want it painted before the float", zoomed[1].pc.rect)
132+ }
133+ pr, zr := plain[4].pc.rect, zoomed[4].pc.rect
134+ if !(zr[2]-zr[0] < pr[2]-pr[0]) || zr[0] != pr[0] || zr[1] != pr[1] {
135+ t.Fatalf("zoomed content %v, want smaller than %v from the same origin", zr, pr)
136+ }
137+ // A card (Preview) keeps not opening the floats: no tile lines, no veil.
138+ card := scene(0)
139+ card.Windows[1].Preview = 0.9
140+ if ds := walk(card); len(ds) != 5 || ds[1].pc.color[3] != 0.5 {
141+ t.Fatalf("card draws = %d, want 5 (lines after, no veil): %+v", len(ds), ds)
142+ }
143+}
M internal/ports/scene.go +6 -1
@@ -430,8 +430,13 @@ type SceneWindow struct {
430430 // needs its kind here and in the push constants (a misc flag).
431431 FocusEffect float64
432432 // Preview, above 0, draws the window's surfaces that much smaller in
433- // Rect (an overview thumbnail): the client keeps its size.
433+ // Rect (an overview thumbnail): the client keeps its size. A preview
434+ // is a card: it never opens the floats.
434435 Preview float64
436+ // Zoom, above 0, is the content scale of a window whose frame is
437+ // animating (appearing, leaving, an overview card in flight): it
438+ // replaces Preview for drawing only; the window keeps its kind.
439+ Zoom float64
435440 // Popups are drawn from their content only: no border, no background.
436441 Popup bool
437442 // OverLayers popups hang from a layer surface: drawn over the top and
@@ -430,8 +430,13 @@ type SceneWindow struct {
430 // needs its kind here and in the push constants (a misc flag).430 // needs its kind here and in the push constants (a misc flag).
431 FocusEffect float64431 FocusEffect float64
432 // Preview, above 0, draws the window's surfaces that much smaller in432 // Preview, above 0, draws the window's surfaces that much smaller in
433- // Rect (an overview thumbnail): the client keeps its size.433+ // Rect (an overview thumbnail): the client keeps its size. A preview
434+ // is a card: it never opens the floats.
434 Preview float64435 Preview float64
436+ // Zoom, above 0, is the content scale of a window whose frame is
437+ // animating (appearing, leaving, an overview card in flight): it
438+ // replaces Preview for drawing only; the window keeps its kind.
439+ Zoom float64
435 // Popups are drawn from their content only: no border, no background.440 // Popups are drawn from their content only: no border, no background.
436 Popup bool441 Popup bool
437 // OverLayers popups hang from a layer surface: drawn over the top and442 // OverLayers popups hang from a layer surface: drawn over the top and
M internal/ports/scene_same_test.go +1 -0
@@ -38,6 +38,7 @@ func TestSceneSameAs(t *testing.T) {
3838 for name, edit := range map[string]func(*Scene){
3939 "window rect": func(s *Scene) { s.Windows[0].Rect.X++ },
4040 "window fade": func(s *Scene) { s.Windows[0].Fade = 0.5 },
41+ "window zoom": func(s *Scene) { s.Windows[0].Zoom = 0.9 },
4142 "window count": func(s *Scene) { s.Windows = nil },
4243 "security": func(s *Scene) { s.Security.Generation++ },
4344 "background": func(s *Scene) { s.Background = "#111111" },
@@ -38,6 +38,7 @@ func TestSceneSameAs(t *testing.T) {
38 for name, edit := range map[string]func(*Scene){38 for name, edit := range map[string]func(*Scene){
39 "window rect": func(s *Scene) { s.Windows[0].Rect.X++ },39 "window rect": func(s *Scene) { s.Windows[0].Rect.X++ },
40 "window fade": func(s *Scene) { s.Windows[0].Fade = 0.5 },40 "window fade": func(s *Scene) { s.Windows[0].Fade = 0.5 },
41+ "window zoom": func(s *Scene) { s.Windows[0].Zoom = 0.9 },
41 "window count": func(s *Scene) { s.Windows = nil },42 "window count": func(s *Scene) { s.Windows = nil },
42 "security": func(s *Scene) { s.Security.Generation++ },43 "security": func(s *Scene) { s.Security.Generation++ },
43 "background": func(s *Scene) { s.Background = "#111111" },44 "background": func(s *Scene) { s.Background = "#111111" },

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 23

Tags 10

↑↓ moveenter openesc close