detached 575513d

Merge pull request 'refactor(core): one viewport visibility rule; tiles never draw over panels' (#26) from refactor/viewport-visibility into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/26
commit
575513dae172b4791b3b264a0f56555be5f0ca3b
author
bnema · · 2026-10-08 16:19 UTC
merge of
7ab00e3 + e36c4f5 · diff against the first parent
signature
unsigned
28 files · +574 -81
M internal/adapters/drm/overlay.go +9 -4
@@ -76,7 +76,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
7676 var mode colorMode
7777 for i := range s.Windows {
7878 w := &s.Windows[i]
79- if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
79+ if !s.Draws(*w) {
80+ // Not drawn (a tile wholly under a panel included): neither
81+ // a candidate nor over one.
8082 continue
8183 }
8284 if w.Popup {
@@ -104,7 +106,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
104106 }
105107 if pick != nil {
106108 for i := range s.Windows {
107- if w := &s.Windows[i]; w.Popup && !w.Hidden && w.Rect.Overlaps(pick.Rect) {
109+ if w := &s.Windows[i]; w.Popup && s.Draws(*w) && w.Rect.Overlaps(pick.Rect) {
108110 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "window_above"
109111 }
110112 }
@@ -148,11 +150,14 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
148150 }
149151 }
150152 }
151- if clip := s.WorkspaceClip; clip != (ports.Rect{}) &&
152- (pick.Rect.X < clip.X || pick.Rect.Y < clip.Y || pick.Rect.X+pick.Rect.W > clip.X+clip.W || pick.Rect.Y+pick.Rect.H > clip.Y+clip.H) {
153+ if clip := s.WorkspaceClip; clip != (ports.Rect{}) && !clip.Contains(pick.Rect) {
153154 // The plane cannot apply the workspace's logical viewport crop.
154155 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "workspace_clip"
155156 }
157+ if a, ok := s.TileArea(); ok && pick.Tile() && !a.Contains(pick.Rect) {
158+ // Nor the cut of a tile at a panel.
159+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "tile_clip"
160+ }
156161 c := surfaces[pick.ID]
157162 pw, ph := float64(pick.Rect.W)*scale, float64(pick.Rect.H)*scale
158163 if float64(c.Width) != pw || float64(c.Height) != ph || c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0) {
@@ -76,7 +76,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
76 var mode colorMode76 var mode colorMode
77 for i := range s.Windows {77 for i := range s.Windows {
78 w := &s.Windows[i]78 w := &s.Windows[i]
79- if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {79+ if !s.Draws(*w) {
80+ // Not drawn (a tile wholly under a panel included): neither
81+ // a candidate nor over one.
80 continue82 continue
81 }83 }
82 if w.Popup {84 if w.Popup {
@@ -104,7 +106,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
104 }106 }
105 if pick != nil {107 if pick != nil {
106 for i := range s.Windows {108 for i := range s.Windows {
107- if w := &s.Windows[i]; w.Popup && !w.Hidden && w.Rect.Overlaps(pick.Rect) {109+ if w := &s.Windows[i]; w.Popup && s.Draws(*w) && w.Rect.Overlaps(pick.Rect) {
108 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "window_above"110 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "window_above"
109 }111 }
110 }112 }
@@ -148,11 +150,14 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
148 }150 }
149 }151 }
150 }152 }
151- if clip := s.WorkspaceClip; clip != (ports.Rect{}) &&153+ if clip := s.WorkspaceClip; clip != (ports.Rect{}) && !clip.Contains(pick.Rect) {
152- (pick.Rect.X < clip.X || pick.Rect.Y < clip.Y || pick.Rect.X+pick.Rect.W > clip.X+clip.W || pick.Rect.Y+pick.Rect.H > clip.Y+clip.H) {
153 // The plane cannot apply the workspace's logical viewport crop.154 // The plane cannot apply the workspace's logical viewport crop.
154 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "workspace_clip"155 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "workspace_clip"
155 }156 }
157+ if a, ok := s.TileArea(); ok && pick.Tile() && !a.Contains(pick.Rect) {
158+ // Nor the cut of a tile at a panel.
159+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "tile_clip"
160+ }
156 c := surfaces[pick.ID]161 c := surfaces[pick.ID]
157 pw, ph := float64(pick.Rect.W)*scale, float64(pick.Rect.H)*scale162 pw, ph := float64(pick.Rect.W)*scale, float64(pick.Rect.H)*scale
158 if float64(c.Width) != pw || float64(c.Height) != ph || c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0) {163 if float64(c.Width) != pw || float64(c.Height) != ph || c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0) {
M internal/adapters/drm/overlay_test.go +31 -0
@@ -63,6 +63,37 @@ func TestOverlayWorkspaceClip(t *testing.T) {
6363 }
6464 }
6565
66+// A tile cut by a panel (TileInset) is composed: the plane would show it
67+// whole, over the panel. A tile wholly under the panel is not drawn, so it
68+// neither takes the plane nor blocks the visible tile.
69+func TestOverlayTileInset(t *testing.T) {
70+ s, contents := overlayScene()
71+ s.TileInset = ports.Insets{Bottom: 20}
72+ if _, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "tile_clip" {
73+ t.Fatalf("tile under a panel on the overlay: %q", reason)
74+ }
75+ // The next cascade band starts under the panel.
76+ s, contents = overlayScene()
77+ s.OutputHeight = 120
78+ s.TileInset = ports.Insets{Bottom: 20}
79+ s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{Y: 100, W: 100, H: 100}})
80+ contents[3] = ports.SurfaceContent{ID: 3, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{ID: 10, Width: 100, Height: 100, Format: fourccXRGB}}
81+ if win, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "" || win.ID != 2 {
82+ t.Fatalf("visible tile with one under the panel: %v %q", win.ID, reason)
83+ }
84+}
85+
86+// A fullscreen window is scanned out when the only other window is a tile
87+// the scene does not draw (no room left by the panels).
88+func TestScanoutIgnoresUndrawnTile(t *testing.T) {
89+ s, contents := fullscreenScene()
90+ s.TileInset = ports.Insets{Bottom: 100}
91+ s.Windows = append([]ports.SceneWindow{{ID: 2, Rect: ports.Rect{W: 200, H: 100}}}, s.Windows...)
92+ if _, reason := scanoutCandidate(s, contents, 200, 100, nil); reason != "" {
93+ t.Fatalf("undrawn tile blocked scanout: %q", reason)
94+ }
95+}
96+
6697 func TestOverlayCandidate(t *testing.T) {
6798 s, c := overlayScene()
6899 if w, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" || w.ID != 2 {
@@ -63,6 +63,37 @@ func TestOverlayWorkspaceClip(t *testing.T) {
63 }63 }
64 }64 }
65 65
66+// A tile cut by a panel (TileInset) is composed: the plane would show it
67+// whole, over the panel. A tile wholly under the panel is not drawn, so it
68+// neither takes the plane nor blocks the visible tile.
69+func TestOverlayTileInset(t *testing.T) {
70+ s, contents := overlayScene()
71+ s.TileInset = ports.Insets{Bottom: 20}
72+ if _, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "tile_clip" {
73+ t.Fatalf("tile under a panel on the overlay: %q", reason)
74+ }
75+ // The next cascade band starts under the panel.
76+ s, contents = overlayScene()
77+ s.OutputHeight = 120
78+ s.TileInset = ports.Insets{Bottom: 20}
79+ s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{Y: 100, W: 100, H: 100}})
80+ contents[3] = ports.SurfaceContent{ID: 3, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{ID: 10, Width: 100, Height: 100, Format: fourccXRGB}}
81+ if win, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "" || win.ID != 2 {
82+ t.Fatalf("visible tile with one under the panel: %v %q", win.ID, reason)
83+ }
84+}
85+
86+// A fullscreen window is scanned out when the only other window is a tile
87+// the scene does not draw (no room left by the panels).
88+func TestScanoutIgnoresUndrawnTile(t *testing.T) {
89+ s, contents := fullscreenScene()
90+ s.TileInset = ports.Insets{Bottom: 100}
91+ s.Windows = append([]ports.SceneWindow{{ID: 2, Rect: ports.Rect{W: 200, H: 100}}}, s.Windows...)
92+ if _, reason := scanoutCandidate(s, contents, 200, 100, nil); reason != "" {
93+ t.Fatalf("undrawn tile blocked scanout: %q", reason)
94+ }
95+}
96+
66 func TestOverlayCandidate(t *testing.T) {97 func TestOverlayCandidate(t *testing.T) {
67 s, c := overlayScene()98 s, c := overlayScene()
68 if w, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" || w.ID != 2 {99 if w, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" || w.ID != 2 {
M internal/adapters/drm/scanout.go +1 -1
@@ -100,7 +100,7 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
100100 var full *ports.SceneWindow
101101 for i := range s.Windows {
102102 win := &s.Windows[i]
103- if win.Hidden || win.Rect.W <= 0 || win.Rect.H <= 0 {
103+ if !s.Draws(*win) {
104104 continue
105105 }
106106 if !win.Fullscreen || !covers(&s, win) || full != nil {
@@ -100,7 +100,7 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
100 var full *ports.SceneWindow100 var full *ports.SceneWindow
101 for i := range s.Windows {101 for i := range s.Windows {
102 win := &s.Windows[i]102 win := &s.Windows[i]
103- if win.Hidden || win.Rect.W <= 0 || win.Rect.H <= 0 {103+ if !s.Draws(*win) {
104 continue104 continue
105 }105 }
106 if !win.Fullscreen || !covers(&s, win) || full != nil {106 if !win.Fullscreen || !covers(&s, win) || full != nil {
M internal/adapters/drm/vrr.go +1 -1
@@ -68,7 +68,7 @@ func (o *Output) wantContent(s ports.Scene, surfaces map[ports.WindowID]ports.Su
6868 var full *ports.SceneWindow
6969 for i := range s.Windows {
7070 w := &s.Windows[i]
71- if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
71+ if !s.Draws(*w) {
7272 continue
7373 }
7474 if !w.Fullscreen || !covers(&s, w) || full != nil {
@@ -68,7 +68,7 @@ func (o *Output) wantContent(s ports.Scene, surfaces map[ports.WindowID]ports.Su
68 var full *ports.SceneWindow68 var full *ports.SceneWindow
69 for i := range s.Windows {69 for i := range s.Windows {
70 w := &s.Windows[i]70 w := &s.Windows[i]
71- if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {71+ if !s.Draws(*w) {
72 continue72 continue
73 }73 }
74 if !w.Fullscreen || !covers(&s, w) || full != nil {74 if !w.Fullscreen || !covers(&s, w) || full != nil {
M internal/adapters/vulkan/damage.go +1 -1
@@ -230,7 +230,7 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim
230230 old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight ||
231231 old.Scale != cur.Scale || old.Transform != cur.Transform || old.Off != cur.Off ||
232232 old.Background != cur.Background || old.Border != cur.Border ||
233- old.WorkspaceClip != cur.WorkspaceClip || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind ||
233+ old.WorkspaceClip != cur.WorkspaceClip || old.TileInset != cur.TileInset || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind ||
234234 old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil ||
235235 !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) ||
236236 !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) ||
@@ -230,7 +230,7 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim
230 old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight ||230 old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight ||
231 old.Scale != cur.Scale || old.Transform != cur.Transform || old.Off != cur.Off ||231 old.Scale != cur.Scale || old.Transform != cur.Transform || old.Off != cur.Off ||
232 old.Background != cur.Background || old.Border != cur.Border ||232 old.Background != cur.Background || old.Border != cur.Border ||
233- old.WorkspaceClip != cur.WorkspaceClip || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind ||233+ old.WorkspaceClip != cur.WorkspaceClip || old.TileInset != cur.TileInset || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind ||
234 old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil ||234 old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil ||
235 !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) ||235 !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) ||
236 !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) ||236 !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) ||
M internal/adapters/vulkan/damage_delta_test.go +2 -1
@@ -17,7 +17,7 @@ import (
1717 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta
1818 // lists the fields that force a full redraw.
1919 func TestSceneFieldCount(t *testing.T) {
20- const fields = 20
20+ const fields = 21
2121 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
2222 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
2323 }
@@ -98,6 +98,7 @@ func TestDamageDeltaFullRedraws(t *testing.T) {
9898 "background": func(s *ports.Scene) { s.Background = "#111111" },
9999 "border": func(s *ports.Scene) { s.Border.Width = 2 },
100100 "workspace clip": func(s *ports.Scene) { s.WorkspaceClip = ports.Rect{W: 10, H: 10} },
101+ "tile inset": func(s *ports.Scene) { s.TileInset = ports.Insets{Bottom: 10} },
101102 "dim": func(s *ports.Scene) { s.Dim = 0.5 },
102103 "dim behind": func(s *ports.Scene) { s.DimBehind = true },
103104 "drop hints": func(s *ports.Scene) { s.DropHints = []ports.Rect{{W: 5, H: 5}} },
@@ -17,7 +17,7 @@ import (
17 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta17 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta
18 // lists the fields that force a full redraw.18 // lists the fields that force a full redraw.
19 func TestSceneFieldCount(t *testing.T) {19 func TestSceneFieldCount(t *testing.T) {
20- const fields = 2020+ const fields = 21
21 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),21 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
22 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")22 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
23 }23 }
@@ -98,6 +98,7 @@ func TestDamageDeltaFullRedraws(t *testing.T) {
98 "background": func(s *ports.Scene) { s.Background = "#111111" },98 "background": func(s *ports.Scene) { s.Background = "#111111" },
99 "border": func(s *ports.Scene) { s.Border.Width = 2 },99 "border": func(s *ports.Scene) { s.Border.Width = 2 },
100 "workspace clip": func(s *ports.Scene) { s.WorkspaceClip = ports.Rect{W: 10, H: 10} },100 "workspace clip": func(s *ports.Scene) { s.WorkspaceClip = ports.Rect{W: 10, H: 10} },
101+ "tile inset": func(s *ports.Scene) { s.TileInset = ports.Insets{Bottom: 10} },
101 "dim": func(s *ports.Scene) { s.Dim = 0.5 },102 "dim": func(s *ports.Scene) { s.Dim = 0.5 },
102 "dim behind": func(s *ports.Scene) { s.DimBehind = true },103 "dim behind": func(s *ports.Scene) { s.DimBehind = true },
103 "drop hints": func(s *ports.Scene) { s.DropHints = []ports.Rect{{W: 5, H: 5}} },104 "drop hints": func(s *ports.Scene) { s.DropHints = []ports.Rect{{W: 5, H: 5}} },
M internal/adapters/vulkan/scene.go +26 -5
@@ -28,7 +28,10 @@ type sceneWalk struct {
2828 // it (premultiplied); 1 otherwise.
2929 alpha float32
3030 bounds image.Rectangle
31- draws []draw
31+ // tileBounds is bounds cut to Scene.TileArea: where tiles and their
32+ // lines draw.
33+ tileBounds image.Rectangle
34+ draws []draw
3235 }
3336
3437 // draws walks the scene into quads in paint order.
@@ -48,6 +51,10 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
4851 c := s.WorkspaceClip
4952 w.bounds = w.bounds.Intersect(w.physRect(c.X, c.Y, c.W, c.H))
5053 }
54+ w.tileBounds = w.bounds
55+ if a, ok := s.TileArea(); ok {
56+ w.tileBounds = w.bounds.Intersect(w.physRect(a.X, a.Y, a.W, a.H))
57+ }
5158 w.windows()
5259 w.popups(false)
5360 w.dropHints()
@@ -207,7 +214,7 @@ func (w *sceneWalk) windows() {
207214 continue
208215 }
209216 if opensFloats(win) && !tileLines {
210- w.separators(0)
217+ w.tileSeparators()
211218 tileLines = true
212219 if w.s.Dim > 0 && !w.s.DimBehind {
213220 w.dim(w.bounds, w.s.Dim)
@@ -219,6 +226,11 @@ func (w *sceneWalk) windows() {
219226 continue
220227 }
221228 w.alpha = 1 - float32(max(0, win.Fade))
229+ windowBounds := w.bounds
230+ if win.Tile() {
231+ // A tile never draws over a panel (Scene.TileArea).
232+ w.bounds = w.tileBounds
233+ }
222234 // Content sits inside the border; core sized the client to match.
223235 b, inset := 0, ports.Sides(0)
224236 if !win.Fullscreen {
@@ -227,7 +239,8 @@ func (w *sceneWalk) windows() {
227239 c := win.Rect.Inset(inset, b)
228240 body := w.physRect(c.X, c.Y, c.W, c.H)
229241 content := w.contents[win.ID]
230- w.dmg.window(win.ID, content, w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H))
242+ // Damage counts only what is drawn: never the panel under a tile.
243+ w.dmg.window(win.ID, content, w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H).Intersect(w.bounds))
231244 // Until its first buffer, a window shows the background: no flash.
232245 w.fill(body, parseColor(w.s.Background))
233246 if !content.Empty() {
@@ -249,13 +262,21 @@ func (w *sceneWalk) windows() {
249262 // A stashed window peeking in: dimmed with its border.
250263 w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim)
251264 }
252- w.alpha = 1
265+ w.alpha, w.bounds = 1, windowBounds
253266 }
254267 if !tileLines {
255- w.separators(0)
268+ w.tileSeparators()
256269 }
257270 }
258271
272+// tileSeparators draws the tile lines, cut like the tiles at the panels.
273+func (w *sceneWalk) tileSeparators() {
274+ b := w.bounds
275+ w.bounds = w.tileBounds
276+ w.separators(0)
277+ w.bounds = b
278+}
279+
259280 // separators draws a window's border lines in order, active lines over
260281 // inactive ones.
261282 func (w *sceneWalk) separators(id ports.WindowID) {
@@ -28,7 +28,10 @@ type sceneWalk struct {
28 // it (premultiplied); 1 otherwise.28 // it (premultiplied); 1 otherwise.
29 alpha float3229 alpha float32
30 bounds image.Rectangle30 bounds image.Rectangle
31- draws []draw31+ // tileBounds is bounds cut to Scene.TileArea: where tiles and their
32+ // lines draw.
33+ tileBounds image.Rectangle
34+ draws []draw
32 }35 }
33 36
34 // draws walks the scene into quads in paint order.37 // draws walks the scene into quads in paint order.
@@ -48,6 +51,10 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
48 c := s.WorkspaceClip51 c := s.WorkspaceClip
49 w.bounds = w.bounds.Intersect(w.physRect(c.X, c.Y, c.W, c.H))52 w.bounds = w.bounds.Intersect(w.physRect(c.X, c.Y, c.W, c.H))
50 }53 }
54+ w.tileBounds = w.bounds
55+ if a, ok := s.TileArea(); ok {
56+ w.tileBounds = w.bounds.Intersect(w.physRect(a.X, a.Y, a.W, a.H))
57+ }
51 w.windows()58 w.windows()
52 w.popups(false)59 w.popups(false)
53 w.dropHints()60 w.dropHints()
@@ -207,7 +214,7 @@ func (w *sceneWalk) windows() {
207 continue214 continue
208 }215 }
209 if opensFloats(win) && !tileLines {216 if opensFloats(win) && !tileLines {
210- w.separators(0)217+ w.tileSeparators()
211 tileLines = true218 tileLines = true
212 if w.s.Dim > 0 && !w.s.DimBehind {219 if w.s.Dim > 0 && !w.s.DimBehind {
213 w.dim(w.bounds, w.s.Dim)220 w.dim(w.bounds, w.s.Dim)
@@ -219,6 +226,11 @@ func (w *sceneWalk) windows() {
219 continue226 continue
220 }227 }
221 w.alpha = 1 - float32(max(0, win.Fade))228 w.alpha = 1 - float32(max(0, win.Fade))
229+ windowBounds := w.bounds
230+ if win.Tile() {
231+ // A tile never draws over a panel (Scene.TileArea).
232+ w.bounds = w.tileBounds
233+ }
222 // Content sits inside the border; core sized the client to match.234 // Content sits inside the border; core sized the client to match.
223 b, inset := 0, ports.Sides(0)235 b, inset := 0, ports.Sides(0)
224 if !win.Fullscreen {236 if !win.Fullscreen {
@@ -227,7 +239,8 @@ func (w *sceneWalk) windows() {
227 c := win.Rect.Inset(inset, b)239 c := win.Rect.Inset(inset, b)
228 body := w.physRect(c.X, c.Y, c.W, c.H)240 body := w.physRect(c.X, c.Y, c.W, c.H)
229 content := w.contents[win.ID]241 content := w.contents[win.ID]
230- w.dmg.window(win.ID, content, w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H))242+ // Damage counts only what is drawn: never the panel under a tile.
243+ w.dmg.window(win.ID, content, w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H).Intersect(w.bounds))
231 // Until its first buffer, a window shows the background: no flash.244 // Until its first buffer, a window shows the background: no flash.
232 w.fill(body, parseColor(w.s.Background))245 w.fill(body, parseColor(w.s.Background))
233 if !content.Empty() {246 if !content.Empty() {
@@ -249,13 +262,21 @@ func (w *sceneWalk) windows() {
249 // A stashed window peeking in: dimmed with its border.262 // A stashed window peeking in: dimmed with its border.
250 w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim)263 w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim)
251 }264 }
252- w.alpha = 1265+ w.alpha, w.bounds = 1, windowBounds
253 }266 }
254 if !tileLines {267 if !tileLines {
255- w.separators(0)268+ w.tileSeparators()
256 }269 }
257 }270 }
258 271
272+// tileSeparators draws the tile lines, cut like the tiles at the panels.
273+func (w *sceneWalk) tileSeparators() {
274+ b := w.bounds
275+ w.bounds = w.tileBounds
276+ w.separators(0)
277+ w.bounds = b
278+}
279+
259 // separators draws a window's border lines in order, active lines over280 // separators draws a window's border lines in order, active lines over
260 // inactive ones.281 // inactive ones.
261 func (w *sceneWalk) separators(id ports.WindowID) {282 func (w *sceneWalk) separators(id ports.WindowID) {
M internal/adapters/vulkan/workspace_clip_test.go +60 -0
@@ -8,6 +8,66 @@ import (
88 "github.com/bnema/neferwl/internal/ports"
99 )
1010
11+// A tile and its lines are cut at TileArea, so a bottom layer panel under
12+// them stays visible; a float there is drawn over it.
13+func TestTileInsetKeepsBottomPanel(t *testing.T) {
14+ r, err := New(64, 48)
15+ if err != nil {
16+ t.Skipf("Vulkan unavailable: %v", err)
17+ }
18+ defer r.Close()
19+ red := color.RGBA{255, 0, 0, 255}
20+ green := color.RGBA{0, 255, 0, 255}
21+ blue := color.RGBA{0, 0, 255, 255}
22+ contents := map[ports.WindowID]ports.SurfaceContent{1: solidContent(t, 64, 48, red), 2: solidContent(t, 64, 8, green), 3: solidContent(t, 8, 8, blue)}
23+ scene := ports.Scene{Seq: 1, OutputWidth: 64, OutputHeight: 48, Background: "#000000", TileInset: ports.Insets{Bottom: 8},
24+ Border: ports.Border{Width: 2, Inactive: "#ffffff"},
25+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}}},
26+ Separators: []ports.Separator{{Rect: ports.Rect{X: 30, W: 2, H: 48}}},
27+ Layers: []ports.SceneLayer{{ID: 2, Rect: ports.Rect{Y: 40, W: 64, H: 8}, Layer: ports.LayerBottom}},
28+ }
29+ if err := render(r, scene, contents); err != nil {
30+ t.Fatal(err)
31+ }
32+ pixels := readPixels(t, r)
33+ white := color.RGBA{255, 255, 255, 255}
34+ for point, want := range map[image.Point]color.RGBA{{10, 10}: red, {10, 39}: red, {10, 40}: green, {60, 47}: green, {30, 39}: white, {30, 44}: green} {
35+ if got := pixels.RGBAAt(point.X, point.Y); got != want {
36+ t.Errorf("pixel %v=%v, want %v", point, got, want)
37+ }
38+ }
39+ scene.Seq++
40+ scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}}, {ID: 3, Rect: ports.Rect{Y: 40, W: 8, H: 8}, Floating: true}}
41+ if err := render(r, scene, contents); err != nil {
42+ t.Fatal(err)
43+ }
44+ if got := readPixels(t, r).RGBAAt(2, 44); got != blue {
45+ t.Fatalf("float clipped at the panel: %v", got)
46+ }
47+}
48+
49+// A tile whose content changes damages only what is drawn of it, never the
50+// panel under it.
51+func TestTileDamageStopsAtPanel(t *testing.T) {
52+ r, err := New(64, 48)
53+ if err != nil {
54+ t.Skipf("Vulkan unavailable: %v", err)
55+ }
56+ defer r.Close()
57+ scene := ports.Scene{Seq: 1, Scale: 1, OutputWidth: 64, OutputHeight: 48, Background: "#000000", TileInset: ports.Insets{Bottom: 8},
58+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}}}}
59+ c := solidContent(t, 64, 48, color.RGBA{255, 0, 0, 255})
60+ c.ID, c.Seq = 1, 2 // the target holds Seq 1, with no damage history
61+ bounds := image.Rect(0, 0, 64, 48)
62+ held := &target{valid: true, sceneSeq: scene.Seq, scene: scene, windows: map[ports.WindowID]heldWindow{1: {seq: 1, rect: image.Rect(0, 0, 64, 40)}}}
63+ dmg := r.frameDamage(held, scene, bounds)
64+ _ = r.draws(scene, map[ports.WindowID]ports.SurfaceContent{1: c}, dmg)
65+ dmg.finish()
66+ if dmg.all() || dmg.area != image.Rect(0, 0, 64, 40) {
67+ t.Fatalf("damage %v (all %v), want the tile above the panel", dmg.area, dmg.all())
68+ }
69+}
70+
1171 func TestWorkspaceClipWindowsPopupsAndLayers(t *testing.T) {
1272 r, err := New(64, 48)
1373 if err != nil {
@@ -8,6 +8,66 @@ import (
8 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
9 )9 )
10 10
11+// A tile and its lines are cut at TileArea, so a bottom layer panel under
12+// them stays visible; a float there is drawn over it.
13+func TestTileInsetKeepsBottomPanel(t *testing.T) {
14+ r, err := New(64, 48)
15+ if err != nil {
16+ t.Skipf("Vulkan unavailable: %v", err)
17+ }
18+ defer r.Close()
19+ red := color.RGBA{255, 0, 0, 255}
20+ green := color.RGBA{0, 255, 0, 255}
21+ blue := color.RGBA{0, 0, 255, 255}
22+ contents := map[ports.WindowID]ports.SurfaceContent{1: solidContent(t, 64, 48, red), 2: solidContent(t, 64, 8, green), 3: solidContent(t, 8, 8, blue)}
23+ scene := ports.Scene{Seq: 1, OutputWidth: 64, OutputHeight: 48, Background: "#000000", TileInset: ports.Insets{Bottom: 8},
24+ Border: ports.Border{Width: 2, Inactive: "#ffffff"},
25+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}}},
26+ Separators: []ports.Separator{{Rect: ports.Rect{X: 30, W: 2, H: 48}}},
27+ Layers: []ports.SceneLayer{{ID: 2, Rect: ports.Rect{Y: 40, W: 64, H: 8}, Layer: ports.LayerBottom}},
28+ }
29+ if err := render(r, scene, contents); err != nil {
30+ t.Fatal(err)
31+ }
32+ pixels := readPixels(t, r)
33+ white := color.RGBA{255, 255, 255, 255}
34+ for point, want := range map[image.Point]color.RGBA{{10, 10}: red, {10, 39}: red, {10, 40}: green, {60, 47}: green, {30, 39}: white, {30, 44}: green} {
35+ if got := pixels.RGBAAt(point.X, point.Y); got != want {
36+ t.Errorf("pixel %v=%v, want %v", point, got, want)
37+ }
38+ }
39+ scene.Seq++
40+ scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}}, {ID: 3, Rect: ports.Rect{Y: 40, W: 8, H: 8}, Floating: true}}
41+ if err := render(r, scene, contents); err != nil {
42+ t.Fatal(err)
43+ }
44+ if got := readPixels(t, r).RGBAAt(2, 44); got != blue {
45+ t.Fatalf("float clipped at the panel: %v", got)
46+ }
47+}
48+
49+// A tile whose content changes damages only what is drawn of it, never the
50+// panel under it.
51+func TestTileDamageStopsAtPanel(t *testing.T) {
52+ r, err := New(64, 48)
53+ if err != nil {
54+ t.Skipf("Vulkan unavailable: %v", err)
55+ }
56+ defer r.Close()
57+ scene := ports.Scene{Seq: 1, Scale: 1, OutputWidth: 64, OutputHeight: 48, Background: "#000000", TileInset: ports.Insets{Bottom: 8},
58+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}}}}
59+ c := solidContent(t, 64, 48, color.RGBA{255, 0, 0, 255})
60+ c.ID, c.Seq = 1, 2 // the target holds Seq 1, with no damage history
61+ bounds := image.Rect(0, 0, 64, 48)
62+ held := &target{valid: true, sceneSeq: scene.Seq, scene: scene, windows: map[ports.WindowID]heldWindow{1: {seq: 1, rect: image.Rect(0, 0, 64, 40)}}}
63+ dmg := r.frameDamage(held, scene, bounds)
64+ _ = r.draws(scene, map[ports.WindowID]ports.SurfaceContent{1: c}, dmg)
65+ dmg.finish()
66+ if dmg.all() || dmg.area != image.Rect(0, 0, 64, 40) {
67+ t.Fatalf("damage %v (all %v), want the tile above the panel", dmg.area, dmg.all())
68+ }
69+}
70+
11 func TestWorkspaceClipWindowsPopupsAndLayers(t *testing.T) {71 func TestWorkspaceClipWindowsPopupsAndLayers(t *testing.T) {
12 r, err := New(64, 48)72 r, err := New(64, 48)
13 if err != nil {73 if err != nil {
M internal/adapters/wayland/scale_test.go +4 -0
@@ -118,6 +118,10 @@ func TestPreferredBufferTransform(t *testing.T) {
118118 }
119119 cursorSeen()
120120 requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), cursor, int32(0), int32(0))
121+ // Roundtrip so the server has made it a cursor before the output changes.
122+ if got := cursorSeen(); len(got) != 0 {
123+ t.Fatalf("cursor surface got transforms %v before rotation", got)
124+ }
121125
122126 setOutputs(1, 1)
123127 if got := normal(); !slices.Equal(got, []uint32{1}) {
@@ -118,6 +118,10 @@ func TestPreferredBufferTransform(t *testing.T) {
118 }118 }
119 cursorSeen()119 cursorSeen()
120 requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), cursor, int32(0), int32(0))120 requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), cursor, int32(0), int32(0))
121+ // Roundtrip so the server has made it a cursor before the output changes.
122+ if got := cursorSeen(); len(got) != 0 {
123+ t.Fatalf("cursor surface got transforms %v before rotation", got)
124+ }
121 125
122 setOutputs(1, 1)126 setOutputs(1, 1)
123 if got := normal(); !slices.Equal(got, []uint32{1}) {127 if got := normal(); !slices.Equal(got, []uint32{1}) {
M internal/core/capture_workspace.go +5 -3
@@ -91,7 +91,7 @@ func (c *Core) captureConfigure(sc *screen, p Placement, t configureTarget) (*Pl
9191 // An overview preview keeps its real size and physical state.
9292 return cp, t
9393 }
94- t.area = cw.frame
94+ t.view = whole(cw.frame) // the capture image has no panels
9595 t.focused = false
9696 t.client, t.imposed = Rect{}, false
9797 if !cp.Hidden {
@@ -146,6 +146,8 @@ func (c *Core) captureScene(seq uint64) *ports.Scene {
146146 return nil
147147 }
148148 f := cw.frame
149+ // The capture image has no panels: all of it is seen.
150+ view := whole(f)
149151 rebase := func(r Rect) Rect { r.X, r.Y = r.X-f.X, r.Y-f.Y; return r }
150152 s := &ports.Scene{
151153 Security: c.security,
@@ -153,7 +155,7 @@ func (c *Core) captureScene(seq uint64) *ports.Scene {
153155 Scale: cw.sc.scale, Background: c.cfg.Background.Color,
154156 Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},
155157 Windows: make([]ports.SceneWindow, 0, len(cw.placements)),
156- Dim: floatDim(cw.placements, f, c.cfg.Floating.Dim),
158+ Dim: floatDim(cw.placements, view, c.cfg.Floating.Dim),
157159 }
158160 // A hidden workspace lights nothing: its focus is not shown.
159161 for _, p := range cw.placements {
@@ -164,7 +166,7 @@ func (c *Core) captureScene(seq uint64) *ports.Scene {
164166 w.Dim = c.peekDim(p)
165167 s.Windows = append(s.Windows, w)
166168 }
167- s.Separators = separators(cw.placements, c.cfg.Border.Width, cw.ws.gap(), f, false)
169+ s.Separators = separators(cw.placements, c.cfg.Border.Width, cw.ws.gap(), view, false)
168170 for i := range s.Separators {
169171 s.Separators[i].Rect = rebase(s.Separators[i].Rect)
170172 }
@@ -91,7 +91,7 @@ func (c *Core) captureConfigure(sc *screen, p Placement, t configureTarget) (*Pl
91 // An overview preview keeps its real size and physical state.91 // An overview preview keeps its real size and physical state.
92 return cp, t92 return cp, t
93 }93 }
94- t.area = cw.frame94+ t.view = whole(cw.frame) // the capture image has no panels
95 t.focused = false95 t.focused = false
96 t.client, t.imposed = Rect{}, false96 t.client, t.imposed = Rect{}, false
97 if !cp.Hidden {97 if !cp.Hidden {
@@ -146,6 +146,8 @@ func (c *Core) captureScene(seq uint64) *ports.Scene {
146 return nil146 return nil
147 }147 }
148 f := cw.frame148 f := cw.frame
149+ // The capture image has no panels: all of it is seen.
150+ view := whole(f)
149 rebase := func(r Rect) Rect { r.X, r.Y = r.X-f.X, r.Y-f.Y; return r }151 rebase := func(r Rect) Rect { r.X, r.Y = r.X-f.X, r.Y-f.Y; return r }
150 s := &ports.Scene{152 s := &ports.Scene{
151 Security: c.security,153 Security: c.security,
@@ -153,7 +155,7 @@ func (c *Core) captureScene(seq uint64) *ports.Scene {
153 Scale: cw.sc.scale, Background: c.cfg.Background.Color,155 Scale: cw.sc.scale, Background: c.cfg.Background.Color,
154 Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},156 Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},
155 Windows: make([]ports.SceneWindow, 0, len(cw.placements)),157 Windows: make([]ports.SceneWindow, 0, len(cw.placements)),
156- Dim: floatDim(cw.placements, f, c.cfg.Floating.Dim),158+ Dim: floatDim(cw.placements, view, c.cfg.Floating.Dim),
157 }159 }
158 // A hidden workspace lights nothing: its focus is not shown.160 // A hidden workspace lights nothing: its focus is not shown.
159 for _, p := range cw.placements {161 for _, p := range cw.placements {
@@ -164,7 +166,7 @@ func (c *Core) captureScene(seq uint64) *ports.Scene {
164 w.Dim = c.peekDim(p)166 w.Dim = c.peekDim(p)
165 s.Windows = append(s.Windows, w)167 s.Windows = append(s.Windows, w)
166 }168 }
167- s.Separators = separators(cw.placements, c.cfg.Border.Width, cw.ws.gap(), f, false)169+ s.Separators = separators(cw.placements, c.cfg.Border.Width, cw.ws.gap(), view, false)
168 for i := range s.Separators {170 for i := range s.Separators {
169 s.Separators[i].Rect = rebase(s.Separators[i].Rect)171 s.Separators[i].Rect = rebase(s.Separators[i].Rect)
170 }172 }
M internal/core/capture_workspace_test.go +2 -2
@@ -47,7 +47,7 @@ func TestCaptureConfigureSizesHiddenWindowWithoutShowingIt(t *testing.T) {
4747 c, _ := hiddenCapture(t)
4848 sc := c.screens[0]
4949 phys := Placement{ID: 1, Hidden: true}
50- t0 := configureTarget{output: "A", area: sc.mon.Frame()}
50+ t0 := configureTarget{output: "A", view: sc.mon.viewport()}
5151 cp, tt := c.captureConfigure(sc, phys, t0)
5252 if cp == nil || cp.Hidden {
5353 t.Fatalf("no real placement: %+v", cp)
@@ -80,7 +80,7 @@ func TestCaptureConfigureLeavesOtherWindowsAlone(t *testing.T) {
8080 c, _ := hiddenCapture(t)
8181 sc := c.screens[0]
8282 phys := Placement{ID: 99, Hidden: true}
83- t0 := configureTarget{output: "A", area: sc.mon.Frame()}
83+ t0 := configureTarget{output: "A", view: sc.mon.viewport()}
8484 if cp, tt := c.captureConfigure(sc, phys, t0); cp != nil || tt != t0 {
8585 t.Fatalf("window of another workspace captured: %+v", cp)
8686 }
@@ -47,7 +47,7 @@ func TestCaptureConfigureSizesHiddenWindowWithoutShowingIt(t *testing.T) {
47 c, _ := hiddenCapture(t)47 c, _ := hiddenCapture(t)
48 sc := c.screens[0]48 sc := c.screens[0]
49 phys := Placement{ID: 1, Hidden: true}49 phys := Placement{ID: 1, Hidden: true}
50- t0 := configureTarget{output: "A", area: sc.mon.Frame()}50+ t0 := configureTarget{output: "A", view: sc.mon.viewport()}
51 cp, tt := c.captureConfigure(sc, phys, t0)51 cp, tt := c.captureConfigure(sc, phys, t0)
52 if cp == nil || cp.Hidden {52 if cp == nil || cp.Hidden {
53 t.Fatalf("no real placement: %+v", cp)53 t.Fatalf("no real placement: %+v", cp)
@@ -80,7 +80,7 @@ func TestCaptureConfigureLeavesOtherWindowsAlone(t *testing.T) {
80 c, _ := hiddenCapture(t)80 c, _ := hiddenCapture(t)
81 sc := c.screens[0]81 sc := c.screens[0]
82 phys := Placement{ID: 99, Hidden: true}82 phys := Placement{ID: 99, Hidden: true}
83- t0 := configureTarget{output: "A", area: sc.mon.Frame()}83+ t0 := configureTarget{output: "A", view: sc.mon.viewport()}
84 if cp, tt := c.captureConfigure(sc, phys, t0); cp != nil || tt != t0 {84 if cp, tt := c.captureConfigure(sc, phys, t0); cp != nil || tt != t0 {
85 t.Fatalf("window of another workspace captured: %+v", cp)85 t.Fatalf("window of another workspace captured: %+v", cp)
86 }86 }
M internal/core/configures.go +5 -5
@@ -5,8 +5,8 @@ import "github.com/bnema/neferwl/internal/ports"
55 // configureTarget is where a placement is shown, as the configure needs it.
66 type configureTarget struct {
77 output string
8- area Rect // the output, to tell whether the window is on screen
9- focused bool // activated: focused on the focused output
8+ view viewport // where the window is seen, to tell whether it is
9+ focused bool // activated: focused on the focused output
1010 // client is the placement minus borders; imposed reports core sized a
1111 // floating window (native floats pick their own size).
1212 client Rect
@@ -109,14 +109,14 @@ func build(p Placement, t configureTarget, old ports.ConfigureWindow, ok bool) p
109109 if p.Fullscreen && !v.Fullscreen {
110110 // A float made fullscreen off screen: its preview row shows
111111 // it fullscreen, sized for the output, never the preview.
112- v.Width, v.Height, v.Fullscreen, v.Floating = t.area.W, t.area.H, true, false
112+ v.Width, v.Height, v.Fullscreen, v.Floating = t.view.frame.W, t.view.frame.H, true, false
113113 }
114114 if t.realTiled {
115115 v.Width, v.Height = t.client.W, t.client.H
116116 }
117- v.Activated, v.Visible, v.Output, v.Captured = t.focused, onScreen(p, t.area), t.output, false
117+ v.Activated, v.Visible, v.Output, v.Captured = t.focused, t.view.shows(p), t.output, false
118118 default:
119- v = ports.ConfigureWindow{ID: p.ID, Width: t.client.W, Height: t.client.H, Fullscreen: p.Fullscreen, Activated: t.focused, Floating: p.Floating && !p.Fullscreen, Output: t.output, Visible: onScreen(p, t.area)}
119+ v = ports.ConfigureWindow{ID: p.ID, Width: t.client.W, Height: t.client.H, Fullscreen: p.Fullscreen, Activated: t.focused, Floating: p.Floating && !p.Fullscreen, Output: t.output, Visible: t.view.shows(p)}
120120 if v.Floating && !t.imposed {
121121 v.Width, v.Height = 0, 0
122122 }
@@ -5,8 +5,8 @@ import "github.com/bnema/neferwl/internal/ports"
5 // configureTarget is where a placement is shown, as the configure needs it.5 // configureTarget is where a placement is shown, as the configure needs it.
6 type configureTarget struct {6 type configureTarget struct {
7 output string7 output string
8- area Rect // the output, to tell whether the window is on screen8+ view viewport // where the window is seen, to tell whether it is
9- focused bool // activated: focused on the focused output9+ focused bool // activated: focused on the focused output
10 // client is the placement minus borders; imposed reports core sized a10 // client is the placement minus borders; imposed reports core sized a
11 // floating window (native floats pick their own size).11 // floating window (native floats pick their own size).
12 client Rect12 client Rect
@@ -109,14 +109,14 @@ func build(p Placement, t configureTarget, old ports.ConfigureWindow, ok bool) p
109 if p.Fullscreen && !v.Fullscreen {109 if p.Fullscreen && !v.Fullscreen {
110 // A float made fullscreen off screen: its preview row shows110 // A float made fullscreen off screen: its preview row shows
111 // it fullscreen, sized for the output, never the preview.111 // it fullscreen, sized for the output, never the preview.
112- v.Width, v.Height, v.Fullscreen, v.Floating = t.area.W, t.area.H, true, false112+ v.Width, v.Height, v.Fullscreen, v.Floating = t.view.frame.W, t.view.frame.H, true, false
113 }113 }
114 if t.realTiled {114 if t.realTiled {
115 v.Width, v.Height = t.client.W, t.client.H115 v.Width, v.Height = t.client.W, t.client.H
116 }116 }
117- v.Activated, v.Visible, v.Output, v.Captured = t.focused, onScreen(p, t.area), t.output, false117+ v.Activated, v.Visible, v.Output, v.Captured = t.focused, t.view.shows(p), t.output, false
118 default:118 default:
119- v = ports.ConfigureWindow{ID: p.ID, Width: t.client.W, Height: t.client.H, Fullscreen: p.Fullscreen, Activated: t.focused, Floating: p.Floating && !p.Fullscreen, Output: t.output, Visible: onScreen(p, t.area)}119+ v = ports.ConfigureWindow{ID: p.ID, Width: t.client.W, Height: t.client.H, Fullscreen: p.Fullscreen, Activated: t.focused, Floating: p.Floating && !p.Fullscreen, Output: t.output, Visible: t.view.shows(p)}
120 if v.Floating && !t.imposed {120 if v.Floating && !t.imposed {
121 v.Width, v.Height = 0, 0121 v.Width, v.Height = 0, 0
122 }122 }
M internal/core/configures_test.go +6 -6
@@ -9,7 +9,7 @@ import (
99 func TestConfiguresNext(t *testing.T) {
1010 area := Rect{W: 1000, H: 800}
1111 tiled := Placement{ID: 1, Rect: Rect{W: 500, H: 800}}
12- target := configureTarget{output: "A", area: area, focused: true, client: Rect{W: 496, H: 796}}
12+ target := configureTarget{output: "A", view: whole(area), focused: true, client: Rect{W: 496, H: 796}}
1313 tests := []struct {
1414 name string
1515 first *Placement // sent before p, with target
@@ -22,14 +22,14 @@ func TestConfiguresNext(t *testing.T) {
2222 {name: "unchanged", first: &tiled, p: tiled, t: target, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Activated: true, Output: "A", Visible: true}},
2323 {name: "scrolled off", p: Placement{ID: 1, Rect: Rect{X: 1000, W: 500, H: 800}}, t: target, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Activated: true, Output: "A"}, send: true},
2424 {name: "native float", p: Placement{ID: 1, Rect: Rect{W: 300, H: 200}, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Activated: true, Floating: true, Output: "A", Visible: true}, send: true},
25- {name: "imposed float", p: Placement{ID: 1, Rect: Rect{W: 300, H: 200}, Floating: true}, t: configureTarget{output: "A", area: area, client: Rect{W: 300, H: 200}, imposed: true}, want: ports.ConfigureWindow{ID: 1, Width: 300, Height: 200, Floating: true, Output: "A", Visible: true}, send: true},
26- {name: "fullscreen float", p: Placement{ID: 1, Rect: area, Floating: true, Fullscreen: true}, t: configureTarget{output: "A", area: area, client: area}, want: ports.ConfigureWindow{ID: 1, Width: 1000, Height: 800, Fullscreen: true, Output: "A", Visible: true}, send: true},
25+ {name: "imposed float", p: Placement{ID: 1, Rect: Rect{W: 300, H: 200}, Floating: true}, t: configureTarget{output: "A", view: whole(area), client: Rect{W: 300, H: 200}, imposed: true}, want: ports.ConfigureWindow{ID: 1, Width: 300, Height: 200, Floating: true, Output: "A", Visible: true}, send: true},
26+ {name: "fullscreen float", p: Placement{ID: 1, Rect: area, Floating: true, Fullscreen: true}, t: configureTarget{output: "A", view: whole(area), client: area}, want: ports.ConfigureWindow{ID: 1, Width: 1000, Height: 800, Fullscreen: true, Output: "A", Visible: true}, send: true},
2727 {name: "hidden new", p: Placement{ID: 1, Hidden: true, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Output: "A"}, send: true},
2828 {name: "hidden keeps size", first: &tiled, p: Placement{ID: 1, Hidden: true}, t: configureTarget{output: "B"}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Output: "B"}, send: true},
2929 {name: "preview new", p: Placement{ID: 1, Rect: Rect{W: 250, H: 200}, Preview: 0.5}, t: target, want: ports.ConfigureWindow{ID: 1, Width: 500, Height: 400, Activated: true, Output: "A", Visible: true}, send: true},
30- {name: "fullscreen preview keeps size and state", first: &Placement{ID: 1, Rect: area, Floating: true, Fullscreen: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Fullscreen: true, Output: "A", Visible: true}, send: true},
31- {name: "hidden float made fullscreen previews fullscreen", first: &Placement{ID: 1, Hidden: true, Floating: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1, Fullscreen: true}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 1000, Height: 800, Fullscreen: true, Output: "A", Visible: true}, send: true},
32- {name: "preview keeps size", first: &tiled, p: Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.5}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Output: "A", Visible: true}, send: true},
30+ {name: "fullscreen preview keeps size and state", first: &Placement{ID: 1, Rect: area, Floating: true, Fullscreen: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1}, t: configureTarget{output: "A", view: whole(area)}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Fullscreen: true, Output: "A", Visible: true}, send: true},
31+ {name: "hidden float made fullscreen previews fullscreen", first: &Placement{ID: 1, Hidden: true, Floating: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1, Fullscreen: true}, t: configureTarget{output: "A", view: whole(area)}, want: ports.ConfigureWindow{ID: 1, Width: 1000, Height: 800, Fullscreen: true, Output: "A", Visible: true}, send: true},
32+ {name: "preview keeps size", first: &tiled, p: Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.5}, t: configureTarget{output: "A", view: whole(area)}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Output: "A", Visible: true}, send: true},
3333 {name: "new float preview stays floating", p: Placement{ID: 1, Rect: Rect{W: 600, H: 480}, Preview: 0.6, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}, send: true},
3434 {name: "covering float preview keeps native size", first: &Placement{ID: 1, Rect: area, Floating: true}, p: Placement{ID: 1, Rect: Rect{W: 600, H: 480}, Preview: 0.6, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}},
3535 {name: "covering float peek keeps native size", first: &Placement{ID: 1, Rect: area, Floating: true}, p: Placement{ID: 1, Rect: Rect{X: 20, Y: 20, W: 600, H: 480}, Preview: 0.6, Floating: true, Peek: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}},
@@ -9,7 +9,7 @@ import (
9 func TestConfiguresNext(t *testing.T) {9 func TestConfiguresNext(t *testing.T) {
10 area := Rect{W: 1000, H: 800}10 area := Rect{W: 1000, H: 800}
11 tiled := Placement{ID: 1, Rect: Rect{W: 500, H: 800}}11 tiled := Placement{ID: 1, Rect: Rect{W: 500, H: 800}}
12- target := configureTarget{output: "A", area: area, focused: true, client: Rect{W: 496, H: 796}}12+ target := configureTarget{output: "A", view: whole(area), focused: true, client: Rect{W: 496, H: 796}}
13 tests := []struct {13 tests := []struct {
14 name string14 name string
15 first *Placement // sent before p, with target15 first *Placement // sent before p, with target
@@ -22,14 +22,14 @@ func TestConfiguresNext(t *testing.T) {
22 {name: "unchanged", first: &tiled, p: tiled, t: target, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Activated: true, Output: "A", Visible: true}},22 {name: "unchanged", first: &tiled, p: tiled, t: target, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Activated: true, Output: "A", Visible: true}},
23 {name: "scrolled off", p: Placement{ID: 1, Rect: Rect{X: 1000, W: 500, H: 800}}, t: target, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Activated: true, Output: "A"}, send: true},23 {name: "scrolled off", p: Placement{ID: 1, Rect: Rect{X: 1000, W: 500, H: 800}}, t: target, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Activated: true, Output: "A"}, send: true},
24 {name: "native float", p: Placement{ID: 1, Rect: Rect{W: 300, H: 200}, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Activated: true, Floating: true, Output: "A", Visible: true}, send: true},24 {name: "native float", p: Placement{ID: 1, Rect: Rect{W: 300, H: 200}, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Activated: true, Floating: true, Output: "A", Visible: true}, send: true},
25- {name: "imposed float", p: Placement{ID: 1, Rect: Rect{W: 300, H: 200}, Floating: true}, t: configureTarget{output: "A", area: area, client: Rect{W: 300, H: 200}, imposed: true}, want: ports.ConfigureWindow{ID: 1, Width: 300, Height: 200, Floating: true, Output: "A", Visible: true}, send: true},25+ {name: "imposed float", p: Placement{ID: 1, Rect: Rect{W: 300, H: 200}, Floating: true}, t: configureTarget{output: "A", view: whole(area), client: Rect{W: 300, H: 200}, imposed: true}, want: ports.ConfigureWindow{ID: 1, Width: 300, Height: 200, Floating: true, Output: "A", Visible: true}, send: true},
26- {name: "fullscreen float", p: Placement{ID: 1, Rect: area, Floating: true, Fullscreen: true}, t: configureTarget{output: "A", area: area, client: area}, want: ports.ConfigureWindow{ID: 1, Width: 1000, Height: 800, Fullscreen: true, Output: "A", Visible: true}, send: true},26+ {name: "fullscreen float", p: Placement{ID: 1, Rect: area, Floating: true, Fullscreen: true}, t: configureTarget{output: "A", view: whole(area), client: area}, want: ports.ConfigureWindow{ID: 1, Width: 1000, Height: 800, Fullscreen: true, Output: "A", Visible: true}, send: true},
27 {name: "hidden new", p: Placement{ID: 1, Hidden: true, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Output: "A"}, send: true},27 {name: "hidden new", p: Placement{ID: 1, Hidden: true, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Output: "A"}, send: true},
28 {name: "hidden keeps size", first: &tiled, p: Placement{ID: 1, Hidden: true}, t: configureTarget{output: "B"}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Output: "B"}, send: true},28 {name: "hidden keeps size", first: &tiled, p: Placement{ID: 1, Hidden: true}, t: configureTarget{output: "B"}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Output: "B"}, send: true},
29 {name: "preview new", p: Placement{ID: 1, Rect: Rect{W: 250, H: 200}, Preview: 0.5}, t: target, want: ports.ConfigureWindow{ID: 1, Width: 500, Height: 400, Activated: true, Output: "A", Visible: true}, send: true},29 {name: "preview new", p: Placement{ID: 1, Rect: Rect{W: 250, H: 200}, Preview: 0.5}, t: target, want: ports.ConfigureWindow{ID: 1, Width: 500, Height: 400, Activated: true, Output: "A", Visible: true}, send: true},
30- {name: "fullscreen preview keeps size and state", first: &Placement{ID: 1, Rect: area, Floating: true, Fullscreen: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Fullscreen: true, Output: "A", Visible: true}, send: true},30+ {name: "fullscreen preview keeps size and state", first: &Placement{ID: 1, Rect: area, Floating: true, Fullscreen: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1}, t: configureTarget{output: "A", view: whole(area)}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Fullscreen: true, Output: "A", Visible: true}, send: true},
31- {name: "hidden float made fullscreen previews fullscreen", first: &Placement{ID: 1, Hidden: true, Floating: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1, Fullscreen: true}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 1000, Height: 800, Fullscreen: true, Output: "A", Visible: true}, send: true},31+ {name: "hidden float made fullscreen previews fullscreen", first: &Placement{ID: 1, Hidden: true, Floating: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1, Fullscreen: true}, t: configureTarget{output: "A", view: whole(area)}, want: ports.ConfigureWindow{ID: 1, Width: 1000, Height: 800, Fullscreen: true, Output: "A", Visible: true}, send: true},
32- {name: "preview keeps size", first: &tiled, p: Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.5}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Output: "A", Visible: true}, send: true},32+ {name: "preview keeps size", first: &tiled, p: Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.5}, t: configureTarget{output: "A", view: whole(area)}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Output: "A", Visible: true}, send: true},
33 {name: "new float preview stays floating", p: Placement{ID: 1, Rect: Rect{W: 600, H: 480}, Preview: 0.6, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}, send: true},33 {name: "new float preview stays floating", p: Placement{ID: 1, Rect: Rect{W: 600, H: 480}, Preview: 0.6, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}, send: true},
34 {name: "covering float preview keeps native size", first: &Placement{ID: 1, Rect: area, Floating: true}, p: Placement{ID: 1, Rect: Rect{W: 600, H: 480}, Preview: 0.6, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}},34 {name: "covering float preview keeps native size", first: &Placement{ID: 1, Rect: area, Floating: true}, p: Placement{ID: 1, Rect: Rect{W: 600, H: 480}, Preview: 0.6, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}},
35 {name: "covering float peek keeps native size", first: &Placement{ID: 1, Rect: area, Floating: true}, p: Placement{ID: 1, Rect: Rect{X: 20, Y: 20, W: 600, H: 480}, Preview: 0.6, Floating: true, Peek: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}},35 {name: "covering float peek keeps native size", first: &Placement{ID: 1, Rect: area, Floating: true}, p: Placement{ID: 1, Rect: Rect{X: 20, Y: 20, W: 600, H: 480}, Preview: 0.6, Floating: true, Peek: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}},
M internal/core/core.go +36 -8
@@ -458,10 +458,36 @@ func (c *Core) clientRect(p Placement) Rect {
458458 return p.Rect.Inset(p.Inset, c.cfg.Border.Width)
459459 }
460460
461-// onScreen reports whether a placement is drawn on its output o: not
462-// hidden and not scrolled off.
463-func onScreen(p Placement, o Rect) bool {
464- return !p.Hidden && p.Rect.Overlaps(o)
461+// viewport is where a workspace is seen: frame is the whole viewport,
462+// usable the part exclusive layers (panels) leave to the tiles.
463+type viewport struct{ frame, usable Rect }
464+
465+// whole is a viewport with no panel: all of r is usable.
466+func whole(r Rect) viewport { return viewport{frame: r, usable: r} }
467+
468+// area is where p can be seen, drawn and pointed at. A tile lives in the
469+// usable area and is cut at its edge (Scene.TileInset), so a cascade band
470+// or a column scrolled under a panel is not seen; a float, a fullscreen
471+// window and an overview preview are placed against the frame.
472+func (v viewport) area(p Placement) Rect {
473+ if p.Floating || p.Fullscreen || p.Preview > 0 {
474+ return v.frame
475+ }
476+ return v.usable
477+}
478+
479+// hasRoom reports whether p's area can show anything: panels can leave
480+// tiles no room at all. Unlike shows, it ignores where p is, so a window
481+// still sliding in counts.
482+func hasRoom(v viewport, p Placement) bool {
483+ a := v.area(p)
484+ return a.W > 0 && a.H > 0
485+}
486+
487+// shows reports whether the user sees p: not hidden, and overlapping its
488+// area.
489+func (v viewport) shows(p Placement) bool {
490+ return !p.Hidden && p.Rect.Overlaps(v.area(p))
465491 }
466492
467493 // floatDim is the veil opacity of a layout: dim only when a float is
@@ -470,9 +496,9 @@ func onScreen(p Placement, o Rect) bool {
470496 // drops the veil at once: a veil that fades changes Scene.Dim every frame,
471497 // which redraws the whole output for the length of the fade, where the
472498 // float's own fade redraws only its rect.
473-func floatDim(layout []Placement, o Rect, dim float64) float64 {
499+func floatDim(layout []Placement, v viewport, dim float64) float64 {
474500 for _, p := range layout {
475- if p.Floating && !p.Below && p.Preview == 0 && onScreen(p, o) {
501+ if p.Floating && !p.Below && p.Preview == 0 && v.shows(p) {
476502 if p.Fullscreen {
477503 return 0
478504 }
@@ -809,8 +835,7 @@ func (c *Core) shownClient(id WindowID) (full, visible Rect, ok bool) {
809835 }
810836 r := c.clientRect(p)
811837 full = Rect{X: r.X + s.x, Y: r.Y + s.y, W: r.W, H: r.H}
812- f := s.mon.Frame()
813- visible = intersect(r, f)
838+ visible = intersect(r, s.mon.viewport().area(p))
814839 visible.X, visible.Y = visible.X+s.x, visible.Y+s.y
815840 if visible.W <= 0 || visible.H <= 0 {
816841 return Rect{}, Rect{}, false
@@ -956,6 +981,7 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
956981 }
957982 var id WindowID
958983 var sx, sy float64
984+ view := sc.mon.viewport()
959985 for _, p := range sc.shownLayout() {
960986 r := c.clientRect(p)
961987 // A peek is clickable wherever it shows, border included: it may
@@ -964,6 +990,8 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
964990 if p.Peek {
965991 box = p.Rect
966992 }
993+ // Only what is drawn takes the pointer: a tile not over a panel.
994+ box = intersect(box, view.area(p))
967995 if !p.Hidden && r.W > 0 && r.H > 0 && lx >= float64(box.X) && lx < float64(box.X+box.W) && ly >= float64(box.Y) && ly < float64(box.Y+box.H) {
968996 if p.Peek {
969997 lx, ly = min(max(lx, float64(r.X)), float64(r.X+r.W-1)), min(max(ly, float64(r.Y)), float64(r.Y+r.H-1))
@@ -458,10 +458,36 @@ func (c *Core) clientRect(p Placement) Rect {
458 return p.Rect.Inset(p.Inset, c.cfg.Border.Width)458 return p.Rect.Inset(p.Inset, c.cfg.Border.Width)
459 }459 }
460 460
461-// onScreen reports whether a placement is drawn on its output o: not461+// viewport is where a workspace is seen: frame is the whole viewport,
462-// hidden and not scrolled off.462+// usable the part exclusive layers (panels) leave to the tiles.
463-func onScreen(p Placement, o Rect) bool {463+type viewport struct{ frame, usable Rect }
464- return !p.Hidden && p.Rect.Overlaps(o)464+
465+// whole is a viewport with no panel: all of r is usable.
466+func whole(r Rect) viewport { return viewport{frame: r, usable: r} }
467+
468+// area is where p can be seen, drawn and pointed at. A tile lives in the
469+// usable area and is cut at its edge (Scene.TileInset), so a cascade band
470+// or a column scrolled under a panel is not seen; a float, a fullscreen
471+// window and an overview preview are placed against the frame.
472+func (v viewport) area(p Placement) Rect {
473+ if p.Floating || p.Fullscreen || p.Preview > 0 {
474+ return v.frame
475+ }
476+ return v.usable
477+}
478+
479+// hasRoom reports whether p's area can show anything: panels can leave
480+// tiles no room at all. Unlike shows, it ignores where p is, so a window
481+// still sliding in counts.
482+func hasRoom(v viewport, p Placement) bool {
483+ a := v.area(p)
484+ return a.W > 0 && a.H > 0
485+}
486+
487+// shows reports whether the user sees p: not hidden, and overlapping its
488+// area.
489+func (v viewport) shows(p Placement) bool {
490+ return !p.Hidden && p.Rect.Overlaps(v.area(p))
465 }491 }
466 492
467 // floatDim is the veil opacity of a layout: dim only when a float is493 // floatDim is the veil opacity of a layout: dim only when a float is
@@ -470,9 +496,9 @@ func onScreen(p Placement, o Rect) bool {
470 // drops the veil at once: a veil that fades changes Scene.Dim every frame,496 // drops the veil at once: a veil that fades changes Scene.Dim every frame,
471 // which redraws the whole output for the length of the fade, where the497 // which redraws the whole output for the length of the fade, where the
472 // float's own fade redraws only its rect.498 // float's own fade redraws only its rect.
473-func floatDim(layout []Placement, o Rect, dim float64) float64 {499+func floatDim(layout []Placement, v viewport, dim float64) float64 {
474 for _, p := range layout {500 for _, p := range layout {
475- if p.Floating && !p.Below && p.Preview == 0 && onScreen(p, o) {501+ if p.Floating && !p.Below && p.Preview == 0 && v.shows(p) {
476 if p.Fullscreen {502 if p.Fullscreen {
477 return 0503 return 0
478 }504 }
@@ -809,8 +835,7 @@ func (c *Core) shownClient(id WindowID) (full, visible Rect, ok bool) {
809 }835 }
810 r := c.clientRect(p)836 r := c.clientRect(p)
811 full = Rect{X: r.X + s.x, Y: r.Y + s.y, W: r.W, H: r.H}837 full = Rect{X: r.X + s.x, Y: r.Y + s.y, W: r.W, H: r.H}
812- f := s.mon.Frame()838+ visible = intersect(r, s.mon.viewport().area(p))
813- visible = intersect(r, f)
814 visible.X, visible.Y = visible.X+s.x, visible.Y+s.y839 visible.X, visible.Y = visible.X+s.x, visible.Y+s.y
815 if visible.W <= 0 || visible.H <= 0 {840 if visible.W <= 0 || visible.H <= 0 {
816 return Rect{}, Rect{}, false841 return Rect{}, Rect{}, false
@@ -956,6 +981,7 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
956 }981 }
957 var id WindowID982 var id WindowID
958 var sx, sy float64983 var sx, sy float64
984+ view := sc.mon.viewport()
959 for _, p := range sc.shownLayout() {985 for _, p := range sc.shownLayout() {
960 r := c.clientRect(p)986 r := c.clientRect(p)
961 // A peek is clickable wherever it shows, border included: it may987 // A peek is clickable wherever it shows, border included: it may
@@ -964,6 +990,8 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
964 if p.Peek {990 if p.Peek {
965 box = p.Rect991 box = p.Rect
966 }992 }
993+ // Only what is drawn takes the pointer: a tile not over a panel.
994+ box = intersect(box, view.area(p))
967 if !p.Hidden && r.W > 0 && r.H > 0 && lx >= float64(box.X) && lx < float64(box.X+box.W) && ly >= float64(box.Y) && ly < float64(box.Y+box.H) {995 if !p.Hidden && r.W > 0 && r.H > 0 && lx >= float64(box.X) && lx < float64(box.X+box.W) && ly >= float64(box.Y) && ly < float64(box.Y+box.H) {
968 if p.Peek {996 if p.Peek {
969 lx, ly = min(max(lx, float64(r.X)), float64(r.X+r.W-1)), min(max(ly, float64(r.Y)), float64(r.Y+r.H-1))997 lx, ly = min(max(lx, float64(r.X)), float64(r.X+r.W-1)), min(max(ly, float64(r.Y)), float64(r.Y+r.H-1))
M internal/core/monitor.go +6 -0
@@ -451,6 +451,12 @@ func (m *Monitor) Frame() Rect {
451451 return m.Current().Output
452452 }
453453
454+// viewport is where the workspace on screen is seen: Frame, and the
455+// usable area its tiles live in.
456+func (m *Monitor) viewport() viewport {
457+ return viewport{frame: m.Frame(), usable: m.Current().Usable}
458+}
459+
454460 // frameHas reports whether the output-local point is inside the frame.
455461 func (m *Monitor) frameHas(x, y float64) bool {
456462 f := m.Frame()
@@ -451,6 +451,12 @@ func (m *Monitor) Frame() Rect {
451 return m.Current().Output451 return m.Current().Output
452 }452 }
453 453
454+// viewport is where the workspace on screen is seen: Frame, and the
455+// usable area its tiles live in.
456+func (m *Monitor) viewport() viewport {
457+ return viewport{frame: m.Frame(), usable: m.Current().Usable}
458+}
459+
454 // frameHas reports whether the output-local point is inside the frame.460 // frameHas reports whether the output-local point is inside the frame.
455 func (m *Monitor) frameHas(x, y float64) bool {461 func (m *Monitor) frameHas(x, y float64) bool {
456 f := m.Frame()462 f := m.Frame()
M internal/core/overview_maximized_test.go +2 -2
@@ -273,7 +273,7 @@ func TestOverviewMaximizedPreviewsKeepConfigures(t *testing.T) {
273273 r := w.columnRectsFor(true)[int(p.ID)-1]
274274 p.Rect, p.Hidden = r, false
275275 }
276- v, _ := s.next(p, configureTarget{output: "test", client: p.Rect, area: w.Usable})
276+ v, _ := s.next(p, configureTarget{output: "test", client: p.Rect, view: whole(w.Usable)})
277277 s.mark(v)
278278 before[p.ID] = v
279279 }
@@ -283,7 +283,7 @@ func TestOverviewMaximizedPreviewsKeepConfigures(t *testing.T) {
283283 if p.ID < 1 || p.ID > 4 {
284284 continue
285285 }
286- v, _ := s.next(p, configureTarget{output: "test", area: w.Usable})
286+ v, _ := s.next(p, configureTarget{output: "test", view: whole(w.Usable)})
287287 if v.Width != before[p.ID].Width || v.Height != before[p.ID].Height {
288288 t.Fatalf("%d resized from %+v to %+v", p.ID, before[p.ID], v)
289289 }
@@ -273,7 +273,7 @@ func TestOverviewMaximizedPreviewsKeepConfigures(t *testing.T) {
273 r := w.columnRectsFor(true)[int(p.ID)-1]273 r := w.columnRectsFor(true)[int(p.ID)-1]
274 p.Rect, p.Hidden = r, false274 p.Rect, p.Hidden = r, false
275 }275 }
276- v, _ := s.next(p, configureTarget{output: "test", client: p.Rect, area: w.Usable})276+ v, _ := s.next(p, configureTarget{output: "test", client: p.Rect, view: whole(w.Usable)})
277 s.mark(v)277 s.mark(v)
278 before[p.ID] = v278 before[p.ID] = v
279 }279 }
@@ -283,7 +283,7 @@ func TestOverviewMaximizedPreviewsKeepConfigures(t *testing.T) {
283 if p.ID < 1 || p.ID > 4 {283 if p.ID < 1 || p.ID > 4 {
284 continue284 continue
285 }285 }
286- v, _ := s.next(p, configureTarget{output: "test", area: w.Usable})286+ v, _ := s.next(p, configureTarget{output: "test", view: whole(w.Usable)})
287 if v.Width != before[p.ID].Width || v.Height != before[p.ID].Height {287 if v.Width != before[p.ID].Width || v.Height != before[p.ID].Height {
288 t.Fatalf("%d resized from %+v to %+v", p.ID, before[p.ID], v)288 t.Fatalf("%d resized from %+v to %+v", p.ID, before[p.ID], v)
289 }289 }
M internal/core/overview_stack_test.go +1 -1
@@ -273,7 +273,7 @@ func TestOverviewStashRightBringsColumnsFront(t *testing.T) {
273273 func TestOverviewStackNoFloatVeil(t *testing.T) {
274274 m := stackMonitor()
275275 m.ToggleOverview()
276- if d := floatDim(m.Layout(), Rect{W: 300, H: 200}, 0.3); d != 0 {
276+ if d := floatDim(m.Layout(), whole(Rect{W: 300, H: 200}), 0.3); d != 0 {
277277 t.Fatalf("veil %v in the overview", d)
278278 }
279279 }
@@ -273,7 +273,7 @@ func TestOverviewStashRightBringsColumnsFront(t *testing.T) {
273 func TestOverviewStackNoFloatVeil(t *testing.T) {273 func TestOverviewStackNoFloatVeil(t *testing.T) {
274 m := stackMonitor()274 m := stackMonitor()
275 m.ToggleOverview()275 m.ToggleOverview()
276- if d := floatDim(m.Layout(), Rect{W: 300, H: 200}, 0.3); d != 0 {276+ if d := floatDim(m.Layout(), whole(Rect{W: 300, H: 200}), 0.3); d != 0 {
277 t.Fatalf("veil %v in the overview", d)277 t.Fatalf("veil %v in the overview", d)
278 }278 }
279 }279 }
M internal/core/popup.go +4 -3
@@ -146,9 +146,10 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) {
146146 for _, sc := range c.screens {
147147 for _, pl := range sc.shownLayout() {
148148 if pl.ID == id {
149- // Hidden or scrolled off: not on screen, like its popups.
150- // A preview takes no input and shows no popups.
151- if !onScreen(pl, sc.mon.Frame()) || pl.Preview > 0 {
149+ // Hidden, scrolled off or under a panel: not on screen,
150+ // like its popups. A preview takes no input and shows no
151+ // popups.
152+ if !sc.mon.viewport().shows(pl) || pl.Preview > 0 {
152153 return nil, Rect{}, false
153154 }
154155 return sc, c.clientRect(pl), true
@@ -146,9 +146,10 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) {
146 for _, sc := range c.screens {146 for _, sc := range c.screens {
147 for _, pl := range sc.shownLayout() {147 for _, pl := range sc.shownLayout() {
148 if pl.ID == id {148 if pl.ID == id {
149- // Hidden or scrolled off: not on screen, like its popups.149+ // Hidden, scrolled off or under a panel: not on screen,
150- // A preview takes no input and shows no popups.150+ // like its popups. A preview takes no input and shows no
151- if !onScreen(pl, sc.mon.Frame()) || pl.Preview > 0 {151+ // popups.
152+ if !sc.mon.viewport().shows(pl) || pl.Preview > 0 {
152 return nil, Rect{}, false153 return nil, Rect{}, false
153 }154 }
154 return sc, c.clientRect(pl), true155 return sc, c.clientRect(pl), true
M internal/core/pulse.go +3 -3
@@ -151,11 +151,11 @@ func (m *Monitor) settled() bool {
151151 return true
152152 }
153153
154-// visibleCount is the number of windows of layout shown in area.
155-func visibleCount(layout []Placement, area Rect) int {
154+// visibleCount is the number of windows of layout seen in v.
155+func visibleCount(layout []Placement, v viewport) int {
156156 n := 0
157157 for _, p := range layout {
158- if p.Preview == 0 && onScreen(p, area) {
158+ if p.Preview == 0 && v.shows(p) {
159159 n++
160160 }
161161 }
@@ -151,11 +151,11 @@ func (m *Monitor) settled() bool {
151 return true151 return true
152 }152 }
153 153
154-// visibleCount is the number of windows of layout shown in area.154+// visibleCount is the number of windows of layout seen in v.
155-func visibleCount(layout []Placement, area Rect) int {155+func visibleCount(layout []Placement, v viewport) int {
156 n := 0156 n := 0
157 for _, p := range layout {157 for _, p := range layout {
158- if p.Preview == 0 && onScreen(p, area) {158+ if p.Preview == 0 && v.shows(p) {
159 n++159 n++
160 }160 }
161 }161 }
M internal/core/scene.go +16 -11
@@ -11,17 +11,23 @@ import (
1111 // drawable reports that it can show the focus pulse on the focused window.
1212 func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, capture *capView, pulse float64) (scene ports.Scene, drawable bool, err error) {
1313 o := sc.mon.Output()
14- // frame is the viewport of the workspace on screen: the whole output
15- // unless it has a size override (never in the overview).
16- frame := sc.mon.Frame()
14+ // view.frame is the viewport of the workspace on screen: the whole
15+ // output unless it has a size override (never in the overview).
16+ view := sc.mon.viewport()
1717 var clip Rect
18- if frame != (Rect{W: o.W, H: o.H}) {
19- clip = frame
18+ if view.frame != (Rect{W: o.W, H: o.H}) {
19+ clip = view.frame
2020 }
2121 // layout is what is drawn; settled (same indexes) is where the
2222 // windows are going, and alone sizes the configures.
2323 layout, settled := sc.shown, sc.settledLayout
2424 scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)), Layers: shownLayers(sc)}
25+ if !sc.mon.ov.open {
26+ // Tiles stop at the panels; the overview lays out previews over
27+ // the whole output.
28+ u := view.usable
29+ scene.TileInset = ports.Insets{Top: u.Y, Left: u.X, Right: o.W - u.X - u.W, Bottom: o.H - u.Y - u.H}
30+ }
2531 var real map[WindowID]Placement
2632 if sc.mon.ov.open {
2733 // Local to this screen's build: reused, cleared each time.
@@ -37,7 +43,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
3743 }
3844 }
3945 }
40- scene.Dim = floatDim(layout, frame, c.cfg.Floating.Dim)
46+ scene.Dim = floatDim(layout, view, c.cfg.Floating.Dim)
4147 if sc.mon.ov.open {
4248 // Darken the wallpaper around the previews.
4349 scene.Dim = c.cfg.Floating.Dim
@@ -52,11 +58,10 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
5258 scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)
5359 } else {
5460 // Only the focused output lights the focused window's lines.
55- scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
61+ scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen)
5662 }
5763 // A window alone on screen needs no pulse to show it has the focus.
58- // A tile under a panel (the next cascade band) does not count.
59- alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, sc.mon.Current().Usable) == 1
64+ alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, view) == 1
6065 for k, p := range layout {
6166 ps := settled[k]
6267 // Only the focused output has an activated window.
@@ -84,14 +89,14 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
8489 }
8590 continue
8691 }
87- if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {
92+ if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open && hasRoom(view, p) {
8893 drawable = true
8994 if p.ID == c.pulse.id {
9095 sw.FocusEffect = pulse
9196 }
9297 }
9398 scene.Windows = append(scene.Windows, sw)
94- t := configureTarget{output: sc.name(), area: frame, focused: focused, captured: capture != nil && capture.window == p.ID}
99+ t := configureTarget{output: sc.name(), view: view, focused: focused, captured: capture != nil && capture.window == p.ID}
95100 if !p.Hidden && p.Preview == 0 {
96101 // Only a sized configure needs the client size.
97102 t.client, t.imposed = c.clientRect(ps), sc.mon.Current().imposedFloat(p.ID)
@@ -11,17 +11,23 @@ import (
11 // drawable reports that it can show the focus pulse on the focused window.11 // drawable reports that it can show the focus pulse on the focused window.
12 func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, capture *capView, pulse float64) (scene ports.Scene, drawable bool, err error) {12 func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, capture *capView, pulse float64) (scene ports.Scene, drawable bool, err error) {
13 o := sc.mon.Output()13 o := sc.mon.Output()
14- // frame is the viewport of the workspace on screen: the whole output14+ // view.frame is the viewport of the workspace on screen: the whole
15- // unless it has a size override (never in the overview).15+ // output unless it has a size override (never in the overview).
16- frame := sc.mon.Frame()16+ view := sc.mon.viewport()
17 var clip Rect17 var clip Rect
18- if frame != (Rect{W: o.W, H: o.H}) {18+ if view.frame != (Rect{W: o.W, H: o.H}) {
19- clip = frame19+ clip = view.frame
20 }20 }
21 // layout is what is drawn; settled (same indexes) is where the21 // layout is what is drawn; settled (same indexes) is where the
22 // windows are going, and alone sizes the configures.22 // windows are going, and alone sizes the configures.
23 layout, settled := sc.shown, sc.settledLayout23 layout, settled := sc.shown, sc.settledLayout
24 scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)), Layers: shownLayers(sc)}24 scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)), Layers: shownLayers(sc)}
25+ if !sc.mon.ov.open {
26+ // Tiles stop at the panels; the overview lays out previews over
27+ // the whole output.
28+ u := view.usable
29+ scene.TileInset = ports.Insets{Top: u.Y, Left: u.X, Right: o.W - u.X - u.W, Bottom: o.H - u.Y - u.H}
30+ }
25 var real map[WindowID]Placement31 var real map[WindowID]Placement
26 if sc.mon.ov.open {32 if sc.mon.ov.open {
27 // Local to this screen's build: reused, cleared each time.33 // Local to this screen's build: reused, cleared each time.
@@ -37,7 +43,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
37 }43 }
38 }44 }
39 }45 }
40- scene.Dim = floatDim(layout, frame, c.cfg.Floating.Dim)46+ scene.Dim = floatDim(layout, view, c.cfg.Floating.Dim)
41 if sc.mon.ov.open {47 if sc.mon.ov.open {
42 // Darken the wallpaper around the previews.48 // Darken the wallpaper around the previews.
43 scene.Dim = c.cfg.Floating.Dim49 scene.Dim = c.cfg.Floating.Dim
@@ -52,11 +58,10 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
52 scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)58 scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)
53 } else {59 } else {
54 // Only the focused output lights the focused window's lines.60 // Only the focused output lights the focused window's lines.
55- scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)61+ scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen)
56 }62 }
57 // A window alone on screen needs no pulse to show it has the focus.63 // A window alone on screen needs no pulse to show it has the focus.
58- // A tile under a panel (the next cascade band) does not count.64+ alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, view) == 1
59- alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, sc.mon.Current().Usable) == 1
60 for k, p := range layout {65 for k, p := range layout {
61 ps := settled[k]66 ps := settled[k]
62 // Only the focused output has an activated window.67 // Only the focused output has an activated window.
@@ -84,14 +89,14 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
84 }89 }
85 continue90 continue
86 }91 }
87- if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {92+ if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open && hasRoom(view, p) {
88 drawable = true93 drawable = true
89 if p.ID == c.pulse.id {94 if p.ID == c.pulse.id {
90 sw.FocusEffect = pulse95 sw.FocusEffect = pulse
91 }96 }
92 }97 }
93 scene.Windows = append(scene.Windows, sw)98 scene.Windows = append(scene.Windows, sw)
94- t := configureTarget{output: sc.name(), area: frame, focused: focused, captured: capture != nil && capture.window == p.ID}99+ t := configureTarget{output: sc.name(), view: view, focused: focused, captured: capture != nil && capture.window == p.ID}
95 if !p.Hidden && p.Preview == 0 {100 if !p.Hidden && p.Preview == 0 {
96 // Only a sized configure needs the client size.101 // Only a sized configure needs the client size.
97 t.client, t.imposed = c.clientRect(ps), sc.mon.Current().imposedFloat(p.ID)102 t.client, t.imposed = c.clientRect(ps), sc.mon.Current().imposedFloat(p.ID)
M internal/core/separators.go +12 -9
@@ -18,19 +18,22 @@ import "github.com/bnema/neferwl/internal/ports"
1818
1919 var sides = [...]ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom}
2020
21-// separators are the lines of a layout on output o, inactive ones first
22-// so active ones draw over them. lit says whether the focused tile shows
23-// focus (only on the focused output).
24-func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separator {
21+// separators are the lines of the windows seen in v, each clipped to its
22+// window's area, inactive ones first so active ones draw over them. lit
23+// says whether the focused tile shows focus (only on the focused output).
24+func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Separator {
2525 if width <= 0 {
2626 return nil
2727 }
28+ o := v.frame
2829 var out []ports.Separator
2930 add := func(p *Placement, r Rect, active bool) {
30- // A line cannot belong to a scrolled-off window or bleed onto
31- // another output, even when a neighboring tile remains visible.
32- x0, y0 := max(r.X, o.X), max(r.Y, o.Y)
33- x1, y1 := min(r.X+r.W, o.X+o.W), min(r.Y+r.H, o.Y+o.H)
31+ // A line cannot belong to a scrolled-off window, bleed onto
32+ // another output or, for a tile, over a panel, even when a
33+ // neighboring tile remains visible.
34+ a := v.area(*p)
35+ x0, y0 := max(r.X, a.X), max(r.Y, a.Y)
36+ x1, y1 := min(r.X+r.W, a.X+a.W), min(r.Y+r.H, a.Y+a.H)
3437 if x0 >= x1 || y0 >= y1 {
3538 return
3639 }
@@ -52,7 +55,7 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa
5255 p := &ps[i]
5356 // A leaving float keeps its own lines while it fades; a leaving
5457 // tile's were shared with its neighbours, which re-flowed.
55- if p.Fullscreen || !(onScreen(*p, o) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {
58+ if p.Fullscreen || !(v.shows(*p) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {
5659 continue
5760 }
5861 // Tiles sharing a line: Neighbors only counts tiles visible in
@@ -18,19 +18,22 @@ import "github.com/bnema/neferwl/internal/ports"
18 18
19 var sides = [...]ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom}19 var sides = [...]ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom}
20 20
21-// separators are the lines of a layout on output o, inactive ones first21+// separators are the lines of the windows seen in v, each clipped to its
22-// so active ones draw over them. lit says whether the focused tile shows22+// window's area, inactive ones first so active ones draw over them. lit
23-// focus (only on the focused output).23+// says whether the focused tile shows focus (only on the focused output).
24-func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separator {24+func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Separator {
25 if width <= 0 {25 if width <= 0 {
26 return nil26 return nil
27 }27 }
28+ o := v.frame
28 var out []ports.Separator29 var out []ports.Separator
29 add := func(p *Placement, r Rect, active bool) {30 add := func(p *Placement, r Rect, active bool) {
30- // A line cannot belong to a scrolled-off window or bleed onto31+ // A line cannot belong to a scrolled-off window, bleed onto
31- // another output, even when a neighboring tile remains visible.32+ // another output or, for a tile, over a panel, even when a
32- x0, y0 := max(r.X, o.X), max(r.Y, o.Y)33+ // neighboring tile remains visible.
33- x1, y1 := min(r.X+r.W, o.X+o.W), min(r.Y+r.H, o.Y+o.H)34+ a := v.area(*p)
35+ x0, y0 := max(r.X, a.X), max(r.Y, a.Y)
36+ x1, y1 := min(r.X+r.W, a.X+a.W), min(r.Y+r.H, a.Y+a.H)
34 if x0 >= x1 || y0 >= y1 {37 if x0 >= x1 || y0 >= y1 {
35 return38 return
36 }39 }
@@ -52,7 +55,7 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa
52 p := &ps[i]55 p := &ps[i]
53 // A leaving float keeps its own lines while it fades; a leaving56 // A leaving float keeps its own lines while it fades; a leaving
54 // tile's were shared with its neighbours, which re-flowed.57 // tile's were shared with its neighbours, which re-flowed.
55- if p.Fullscreen || !(onScreen(*p, o) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {58+ if p.Fullscreen || !(v.shows(*p) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {
56 continue59 continue
57 }60 }
58 // Tiles sharing a line: Neighbors only counts tiles visible in61 // Tiles sharing a line: Neighbors only counts tiles visible in
M internal/core/separators_test.go +13 -8
@@ -66,7 +66,7 @@ func TestSeparators(t *testing.T) {
6666 {"left column: whole line, split column untouched", tiles(0, grid...), true, []px{{14, 3, "lit"}, {14, 30, "lit"}, {20, 14, "gray"}}},
6767 } {
6868 t.Run(tc.name, func(t *testing.T) {
69- seps := separators(tc.ps, 2, 0, bigOutput, tc.lit)
69+ seps := separators(tc.ps, 2, 0, whole(bigOutput), tc.lit)
7070 for _, c := range tc.check {
7171 if got := colorAt(seps, c.x, c.y); got != c.want {
7272 t.Errorf("(%d,%d) = %q, want %q; %v", c.x, c.y, got, c.want, seps)
@@ -77,22 +77,22 @@ func TestSeparators(t *testing.T) {
7777 }
7878
7979 func TestSeparatorsSkip(t *testing.T) {
80- if s := separators(tiles(0, Rect{W: 32, H: 16}), 2, 0, bigOutput, true); len(s) != 0 {
80+ if s := separators(tiles(0, Rect{W: 32, H: 16}), 2, 0, whole(bigOutput), true); len(s) != 0 {
8181 t.Fatal("lone window:", s)
8282 }
83- if s := separators(tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16}), 0, 0, bigOutput, true); len(s) != 0 {
83+ if s := separators(tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16}), 0, 0, whole(bigOutput), true); len(s) != 0 {
8484 t.Fatal("width 0:", s)
8585 }
8686 full := tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16})
8787 full[0].Fullscreen, full[1].Hidden = true, true
88- if s := separators(full, 2, 0, bigOutput, true); len(s) != 0 {
88+ if s := separators(full, 2, 0, whole(bigOutput), true); len(s) != 0 {
8989 t.Fatal("fullscreen:", s)
9090 }
9191 }
9292
9393 func TestSeparatorsFloat(t *testing.T) {
9494 float := Placement{ID: 1, Rect: Rect{X: 10, Y: 10, W: 20, H: 20}, Floating: true, Focused: true, Inset: ports.SideAll}
95- seps := separators([]Placement{float}, 2, 0, bigOutput, true)
95+ seps := separators([]Placement{float}, 2, 0, whole(bigOutput), true)
9696 if seps[0].Window != 1 {
9797 t.Fatal("float border not tied to its window:", seps)
9898 }
@@ -134,7 +134,7 @@ func TestSeparatorsScroll(t *testing.T) {
134134 t.Fatalf("expected visible borders to change with the view: %v then %v", before, after)
135135 }
136136 out := Rect{W: 100, H: 80}
137- seps := separators(w.Layout(), 2, 0, out, true)
137+ seps := separators(w.Layout(), 2, 0, whole(out), true)
138138 if got := colorAt(seps, 99, 40); got != "" {
139139 t.Fatalf("line at the output edge: %v", seps)
140140 }
@@ -183,10 +183,15 @@ func TestSeparatorsCascadeBottomPanel(t *testing.T) {
183183 if p := previewOf(t, ps, 1); p.Neighbors != ports.SideRight {
184184 t.Fatalf("neighbors %04b, want right only", p.Neighbors)
185185 }
186- seps := separators(ps, 2, 0, w.Output, true)
186+ seps := separators(ps, 2, 0, viewport{frame: w.Output, usable: w.Usable}, true)
187187 if got := colorAt(seps, 200, 578); got != "" {
188188 t.Fatalf("line along the panel: %v", seps)
189189 }
190+ for _, s := range seps {
191+ if s.Window == 3 {
192+ t.Fatalf("line of the tile under the panel: %v", seps)
193+ }
194+ }
190195 // Two tiles on screen: each lights its half of the shared line.
191196 if got := colorAt(seps, 449, 100); got != "lit" {
192197 t.Fatalf("shared line (449,100) = %q; %v", got, seps)
@@ -200,7 +205,7 @@ func TestSeparatorsCascadeBottomPanel(t *testing.T) {
200205 func TestSeparatorsGaps(t *testing.T) {
201206 ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}}
202207 setVisibleNeighbors(ps, 4, bigOutput)
203- seps := separators(ps, 2, 4, bigOutput, true)
208+ seps := separators(ps, 2, 4, whole(bigOutput), true)
204209 for _, c := range []px{{14, 2, "lit"}, {14, 13, "lit"}, {20, 8, "gray"}, {17, 8, ""}} {
205210 if got := colorAt(seps, c.x, c.y); got != c.want {
206211 t.Errorf("(%d,%d) = %q, want %q", c.x, c.y, got, c.want)
@@ -66,7 +66,7 @@ func TestSeparators(t *testing.T) {
66 {"left column: whole line, split column untouched", tiles(0, grid...), true, []px{{14, 3, "lit"}, {14, 30, "lit"}, {20, 14, "gray"}}},66 {"left column: whole line, split column untouched", tiles(0, grid...), true, []px{{14, 3, "lit"}, {14, 30, "lit"}, {20, 14, "gray"}}},
67 } {67 } {
68 t.Run(tc.name, func(t *testing.T) {68 t.Run(tc.name, func(t *testing.T) {
69- seps := separators(tc.ps, 2, 0, bigOutput, tc.lit)69+ seps := separators(tc.ps, 2, 0, whole(bigOutput), tc.lit)
70 for _, c := range tc.check {70 for _, c := range tc.check {
71 if got := colorAt(seps, c.x, c.y); got != c.want {71 if got := colorAt(seps, c.x, c.y); got != c.want {
72 t.Errorf("(%d,%d) = %q, want %q; %v", c.x, c.y, got, c.want, seps)72 t.Errorf("(%d,%d) = %q, want %q; %v", c.x, c.y, got, c.want, seps)
@@ -77,22 +77,22 @@ func TestSeparators(t *testing.T) {
77 }77 }
78 78
79 func TestSeparatorsSkip(t *testing.T) {79 func TestSeparatorsSkip(t *testing.T) {
80- if s := separators(tiles(0, Rect{W: 32, H: 16}), 2, 0, bigOutput, true); len(s) != 0 {80+ if s := separators(tiles(0, Rect{W: 32, H: 16}), 2, 0, whole(bigOutput), true); len(s) != 0 {
81 t.Fatal("lone window:", s)81 t.Fatal("lone window:", s)
82 }82 }
83- if s := separators(tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16}), 0, 0, bigOutput, true); len(s) != 0 {83+ if s := separators(tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16}), 0, 0, whole(bigOutput), true); len(s) != 0 {
84 t.Fatal("width 0:", s)84 t.Fatal("width 0:", s)
85 }85 }
86 full := tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16})86 full := tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16})
87 full[0].Fullscreen, full[1].Hidden = true, true87 full[0].Fullscreen, full[1].Hidden = true, true
88- if s := separators(full, 2, 0, bigOutput, true); len(s) != 0 {88+ if s := separators(full, 2, 0, whole(bigOutput), true); len(s) != 0 {
89 t.Fatal("fullscreen:", s)89 t.Fatal("fullscreen:", s)
90 }90 }
91 }91 }
92 92
93 func TestSeparatorsFloat(t *testing.T) {93 func TestSeparatorsFloat(t *testing.T) {
94 float := Placement{ID: 1, Rect: Rect{X: 10, Y: 10, W: 20, H: 20}, Floating: true, Focused: true, Inset: ports.SideAll}94 float := Placement{ID: 1, Rect: Rect{X: 10, Y: 10, W: 20, H: 20}, Floating: true, Focused: true, Inset: ports.SideAll}
95- seps := separators([]Placement{float}, 2, 0, bigOutput, true)95+ seps := separators([]Placement{float}, 2, 0, whole(bigOutput), true)
96 if seps[0].Window != 1 {96 if seps[0].Window != 1 {
97 t.Fatal("float border not tied to its window:", seps)97 t.Fatal("float border not tied to its window:", seps)
98 }98 }
@@ -134,7 +134,7 @@ func TestSeparatorsScroll(t *testing.T) {
134 t.Fatalf("expected visible borders to change with the view: %v then %v", before, after)134 t.Fatalf("expected visible borders to change with the view: %v then %v", before, after)
135 }135 }
136 out := Rect{W: 100, H: 80}136 out := Rect{W: 100, H: 80}
137- seps := separators(w.Layout(), 2, 0, out, true)137+ seps := separators(w.Layout(), 2, 0, whole(out), true)
138 if got := colorAt(seps, 99, 40); got != "" {138 if got := colorAt(seps, 99, 40); got != "" {
139 t.Fatalf("line at the output edge: %v", seps)139 t.Fatalf("line at the output edge: %v", seps)
140 }140 }
@@ -183,10 +183,15 @@ func TestSeparatorsCascadeBottomPanel(t *testing.T) {
183 if p := previewOf(t, ps, 1); p.Neighbors != ports.SideRight {183 if p := previewOf(t, ps, 1); p.Neighbors != ports.SideRight {
184 t.Fatalf("neighbors %04b, want right only", p.Neighbors)184 t.Fatalf("neighbors %04b, want right only", p.Neighbors)
185 }185 }
186- seps := separators(ps, 2, 0, w.Output, true)186+ seps := separators(ps, 2, 0, viewport{frame: w.Output, usable: w.Usable}, true)
187 if got := colorAt(seps, 200, 578); got != "" {187 if got := colorAt(seps, 200, 578); got != "" {
188 t.Fatalf("line along the panel: %v", seps)188 t.Fatalf("line along the panel: %v", seps)
189 }189 }
190+ for _, s := range seps {
191+ if s.Window == 3 {
192+ t.Fatalf("line of the tile under the panel: %v", seps)
193+ }
194+ }
190 // Two tiles on screen: each lights its half of the shared line.195 // Two tiles on screen: each lights its half of the shared line.
191 if got := colorAt(seps, 449, 100); got != "lit" {196 if got := colorAt(seps, 449, 100); got != "lit" {
192 t.Fatalf("shared line (449,100) = %q; %v", got, seps)197 t.Fatalf("shared line (449,100) = %q; %v", got, seps)
@@ -200,7 +205,7 @@ func TestSeparatorsCascadeBottomPanel(t *testing.T) {
200 func TestSeparatorsGaps(t *testing.T) {205 func TestSeparatorsGaps(t *testing.T) {
201 ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}}206 ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}}
202 setVisibleNeighbors(ps, 4, bigOutput)207 setVisibleNeighbors(ps, 4, bigOutput)
203- seps := separators(ps, 2, 4, bigOutput, true)208+ seps := separators(ps, 2, 4, whole(bigOutput), true)
204 for _, c := range []px{{14, 2, "lit"}, {14, 13, "lit"}, {20, 8, "gray"}, {17, 8, ""}} {209 for _, c := range []px{{14, 2, "lit"}, {14, 13, "lit"}, {20, 8, "gray"}, {17, 8, ""}} {
205 if got := colorAt(seps, c.x, c.y); got != c.want {210 if got := colorAt(seps, c.x, c.y); got != c.want {
206 t.Errorf("(%d,%d) = %q, want %q", c.x, c.y, got, c.want)211 t.Errorf("(%d,%d) = %q, want %q", c.x, c.y, got, c.want)
M internal/core/state.go +2 -1
@@ -54,8 +54,9 @@ func (c *Core) stateInto(st ports.State) ports.State {
5454 // Read at once, into publish's scratch: the snapshot holds no
5555 // placement.
5656 c.realBuf = m.layoutInto(c.realBuf)
57+ view := m.viewport()
5758 for _, p := range c.realBuf {
58- shown[p.ID] = onScreen(p, m.Frame())
59+ shown[p.ID] = view.shows(p)
5960 }
6061 for w := range m.all() {
6162 n := indexOf(m.Workspaces, w) + 1
@@ -54,8 +54,9 @@ func (c *Core) stateInto(st ports.State) ports.State {
54 // Read at once, into publish's scratch: the snapshot holds no54 // Read at once, into publish's scratch: the snapshot holds no
55 // placement.55 // placement.
56 c.realBuf = m.layoutInto(c.realBuf)56 c.realBuf = m.layoutInto(c.realBuf)
57+ view := m.viewport()
57 for _, p := range c.realBuf {58 for _, p := range c.realBuf {
58- shown[p.ID] = onScreen(p, m.Frame())59+ shown[p.ID] = view.shows(p)
59 }60 }
60 for w := range m.all() {61 for w := range m.all() {
61 n := indexOf(m.Workspaces, w) + 162 n := indexOf(m.Workspaces, w) + 1
A internal/core/viewport_test.go +235 -0
@@ -0,0 +1,235 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// A tile is seen in the usable area; a float, a fullscreen window and a
12+// preview are placed against the whole frame.
13+func TestViewportShows(t *testing.T) {
14+ v := viewport{frame: Rect{W: 100, H: 80}, usable: Rect{W: 100, H: 60}}
15+ under := Rect{Y: 60, W: 50, H: 60} // the next cascade band, under a bottom panel
16+ for _, tc := range []struct {
17+ name string
18+ v viewport
19+ p Placement
20+ want bool
21+ }{
22+ {"tile in usable", v, Placement{Rect: Rect{W: 50, H: 60}}, true},
23+ {"tile under panel", v, Placement{Rect: under}, false},
24+ {"tile across the panel line", v, Placement{Rect: Rect{Y: 30, W: 50, H: 60}}, true},
25+ {"tile off frame", v, Placement{Rect: Rect{Y: 80, W: 50, H: 60}}, false},
26+ {"hidden tile", v, Placement{Rect: Rect{W: 50, H: 60}, Hidden: true}, false},
27+ {"float under panel", v, Placement{Rect: under, Floating: true}, true},
28+ {"hidden float", v, Placement{Rect: Rect{W: 50, H: 60}, Floating: true, Hidden: true}, false},
29+ {"fullscreen tile", v, Placement{Rect: Rect{W: 100, H: 80}, Fullscreen: true}, true},
30+ {"preview under panel", v, Placement{Rect: under, Preview: 0.5}, true},
31+ {"tile with no usable area", viewport{frame: v.frame}, Placement{Rect: Rect{W: 50, H: 60}}, false},
32+ {"float with no usable area", viewport{frame: v.frame}, Placement{Rect: Rect{W: 50, H: 60}, Floating: true}, true},
33+ } {
34+ if got := tc.v.shows(tc.p); got != tc.want {
35+ t.Errorf("%s: shows %v, want %v", tc.name, got, tc.want)
36+ }
37+ }
38+}
39+
40+// viewportCore is a core with one 100x80 output whose usable area is
41+// usable, publishing into the returned check: it asserts what each
42+// window's configure and the state snapshot say about its visibility.
43+func viewportCore(t *testing.T, layout func(*ports.Config), usable Rect) (*Core, func(map[WindowID]bool) ports.Scene) {
44+ t.Helper()
45+ cfg := ports.Config{}
46+ cfg.Keyboard.CmdKey = "super"
47+ layout(&cfg)
48+ commands := make(chan ports.ClientCommand, 256)
49+ scenes := make(chan []ports.Scene, 1)
50+ c, err := New(cfg, Channels{Commands: commands, Scenes: scenes}, Options{})
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80})
55+ c.cur().mon.SetUsable(usable)
56+ sent := map[WindowID]ports.ConfigureWindow{}
57+ check := func(want map[WindowID]bool) ports.Scene {
58+ t.Helper()
59+ if err := c.publish(context.Background()); err != nil {
60+ t.Fatal(err)
61+ }
62+ scene := (<-scenes)[0]
63+ for len(commands) > 0 {
64+ if v, ok := (<-commands).(ports.ConfigureWindow); ok {
65+ sent[v.ID] = v
66+ }
67+ }
68+ state := map[WindowID]bool{}
69+ for _, s := range c.state().Windows {
70+ state[s.ID] = s.Visible
71+ }
72+ for id, vis := range want {
73+ if v := sent[id]; v.Visible != vis {
74+ t.Fatalf("window %d: configure %+v, want visible %v", id, v, vis)
75+ }
76+ if state[id] != vis {
77+ t.Fatalf("window %d: state visible %v, want %v", id, state[id], vis)
78+ }
79+ }
80+ return scene
81+ }
82+ return c, check
83+}
84+
85+// The next cascade band starts right under a bottom panel: its windows are
86+// not seen, so they are told they are invisible (and throttled), while the
87+// scene still carries them.
88+func TestConfigureVisibleCascadeUnderPanel(t *testing.T) {
89+ c, check := viewportCore(t, func(cfg *ports.Config) {
90+ cfg.Layout.Overflow = "cascade"
91+ cfg.Layout.MaxColumns = 2
92+ }, Rect{W: 100, H: 60})
93+ m := c.cur().mon
94+ w := m.Current()
95+ for id := WindowID(1); id <= 3; id++ {
96+ m.AddWindow(id)
97+ }
98+ w.FocusID(1)
99+ scene := check(map[WindowID]bool{1: true, 2: true, 3: false})
100+ found := false
101+ for _, sw := range scene.Windows {
102+ found = found || sw.ID == 3
103+ }
104+ if !found {
105+ t.Fatal("window under the panel left the scene")
106+ }
107+ // It is cut at the panel and takes no pointer there.
108+ if scene.TileInset != (ports.Insets{Bottom: 20}) {
109+ t.Fatalf("tile inset %+v, want the panel's", scene.TileInset)
110+ }
111+ if id, _, _ := c.hit(10, 70); id != 0 {
112+ t.Fatalf("pointer over the panel went to window %d", id)
113+ }
114+ if id, _, _ := c.hit(10, 30); id != 1 {
115+ t.Fatalf("pointer over window 1 went to %d", id)
116+ }
117+ // Not on screen: its popups are not either, and it loses the pointer.
118+ c.popups[10] = &popupState{id: 10, parent: 3, mapped: true, rect: Rect{W: 5, H: 5}}
119+ c.popupOrder = append(c.popupOrder, 10)
120+ c.pointer = 3
121+ check(nil)
122+ if c.visible(3) || c.visible(10) || c.pointer != 0 {
123+ t.Fatalf("tile under the panel: visible %v, popup %v, pointer %d", c.visible(3), c.visible(10), c.pointer)
124+ }
125+ delete(c.popups, 10)
126+ c.popupOrder = slices.DeleteFunc(c.popupOrder, func(id WindowID) bool { return id == 10 })
127+ // The overview lays previews over the whole output: no inset.
128+ m.ToggleOverview()
129+ if s := check(nil); s.TileInset != (ports.Insets{}) {
130+ t.Fatalf("tile inset %+v in the overview", s.TileInset)
131+ }
132+ m.ToggleOverview()
133+ // Its band scrolled in, the first band is under nothing but off screen.
134+ w.FocusID(3)
135+ check(map[WindowID]bool{1: false, 2: false, 3: true})
136+ // Scrolled back, its band is under the panel again.
137+ w.FocusID(1)
138+ check(map[WindowID]bool{1: true, 3: false})
139+ // Fullscreen covers the panel: always seen.
140+ w.FocusID(3)
141+ w.ToggleFullscreen()
142+ check(map[WindowID]bool{3: true})
143+}
144+
145+// A tile half under a bottom panel: its lines, its pointer bounds (warp,
146+// constraints) and its draw all stop at the panel, and the renderer's tile
147+// rule matches core's.
148+func TestTileStopsAtPanel(t *testing.T) {
149+ c, check := viewportCore(t, func(cfg *ports.Config) {
150+ cfg.Layout.MaxColumns = 2
151+ cfg.Border.Width = 2
152+ }, Rect{W: 100, H: 60})
153+ m := c.cur().mon
154+ m.AddWindow(1)
155+ m.AddWindow(3)
156+ m.Current().ToggleWindowStash() // 3 floats
157+ m.AddWindow(2)
158+ m.Current().FocusID(1)
159+ scene := check(map[WindowID]bool{1: true, 2: true, 3: true})
160+ // Move tile 1 across the panel line, as a slide would.
161+ sc := c.cur()
162+ sc.shown = slices.Clone(sc.shownLayout())
163+ for i := range sc.shown {
164+ if sc.shown[i].ID == 1 {
165+ sc.shown[i].Rect.Y = 30
166+ }
167+ }
168+ if r, ok := c.shownClientRect(1); !ok || r.Y+r.H > 60 {
169+ t.Fatalf("pointer bounds %+v %v reach over the panel", r, ok)
170+ }
171+ for _, s := range separators(sc.shown, 2, 0, m.viewport(), true) {
172+ if s.Window == 0 && s.Rect.Y+s.Rect.H > 60 {
173+ t.Fatalf("tile line over the panel: %+v", s)
174+ }
175+ }
176+ view := m.viewport()
177+ for k, sw := range scene.Windows {
178+ if sw.Popup {
179+ continue
180+ }
181+ if p := sc.settledLayout[k]; sw.Tile() != (view.area(p) == view.usable) {
182+ t.Fatalf("window %d: renderer tile %v, core area %+v", sw.ID, sw.Tile(), view.area(p))
183+ }
184+ }
185+}
186+
187+// Panels that leave no room draw no tile, a leaving one included, and the
188+// focus pulse has nothing to show.
189+func TestNoUsableAreaDrawsNoTile(t *testing.T) {
190+ c, check := viewportCore(t, func(cfg *ports.Config) { cfg.Layout.MaxColumns = 1 }, Rect{})
191+ c.cur().mon.AddWindow(1)
192+ c.pulse.target = 1
193+ scene := check(map[WindowID]bool{1: false})
194+ if scene.Draws(scene.Windows[0]) || c.pulse.drawable {
195+ t.Fatalf("tile drawn %v, pulse drawable %v with no usable area", scene.Draws(scene.Windows[0]), c.pulse.drawable)
196+ }
197+ sc := c.cur()
198+ sc.shown = slices.Clone(sc.shownLayout())
199+ sc.shown[0].Leaving = true
200+ scene, _, err := c.sceneFor(context.Background(), 0, sc, nil, nil, 0)
201+ if err != nil {
202+ t.Fatal(err)
203+ }
204+ if scene.Draws(scene.Windows[0]) {
205+ t.Fatalf("leaving tile drawn with no usable area: %+v", scene.Windows[0])
206+ }
207+}
208+
209+// A sized workspace with a panel: the scene's tile area is the workspace's
210+// usable area, inside its viewport.
211+func TestTileAreaSizedWorkspace(t *testing.T) {
212+ c, check := viewportCore(t, func(cfg *ports.Config) { cfg.Layout.MaxColumns = 1 }, Rect{W: 100, H: 70})
213+ m := c.cur().mon
214+ m.Current().SetSize(60, 80)
215+ m.AddWindow(1)
216+ scene := check(map[WindowID]bool{1: true})
217+ a, ok := scene.TileArea()
218+ if u := m.Current().Usable; !ok || a != u || u == m.Current().Output {
219+ t.Fatalf("tile area %+v %v, want usable %+v inside frame %+v", a, ok, u, m.Current().Output)
220+ }
221+}
222+
223+// A column scrolled under a left panel is not seen.
224+func TestConfigureVisibleScrollUnderSidePanel(t *testing.T) {
225+ c, check := viewportCore(t, func(cfg *ports.Config) {
226+ cfg.Layout.MaxColumns = 1
227+ }, Rect{X: 20, W: 80, H: 80})
228+ m := c.cur().mon
229+ m.AddWindow(1)
230+ m.AddWindow(2)
231+ if r := m.Layout()[0].Rect; r.X >= 20 || r.X+r.W <= 0 {
232+ t.Fatalf("column 1 at %+v, want partly under the panel", r)
233+ }
234+ check(map[WindowID]bool{1: false, 2: true})
235+}
@@ -0,0 +1,235 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// A tile is seen in the usable area; a float, a fullscreen window and a
12+// preview are placed against the whole frame.
13+func TestViewportShows(t *testing.T) {
14+ v := viewport{frame: Rect{W: 100, H: 80}, usable: Rect{W: 100, H: 60}}
15+ under := Rect{Y: 60, W: 50, H: 60} // the next cascade band, under a bottom panel
16+ for _, tc := range []struct {
17+ name string
18+ v viewport
19+ p Placement
20+ want bool
21+ }{
22+ {"tile in usable", v, Placement{Rect: Rect{W: 50, H: 60}}, true},
23+ {"tile under panel", v, Placement{Rect: under}, false},
24+ {"tile across the panel line", v, Placement{Rect: Rect{Y: 30, W: 50, H: 60}}, true},
25+ {"tile off frame", v, Placement{Rect: Rect{Y: 80, W: 50, H: 60}}, false},
26+ {"hidden tile", v, Placement{Rect: Rect{W: 50, H: 60}, Hidden: true}, false},
27+ {"float under panel", v, Placement{Rect: under, Floating: true}, true},
28+ {"hidden float", v, Placement{Rect: Rect{W: 50, H: 60}, Floating: true, Hidden: true}, false},
29+ {"fullscreen tile", v, Placement{Rect: Rect{W: 100, H: 80}, Fullscreen: true}, true},
30+ {"preview under panel", v, Placement{Rect: under, Preview: 0.5}, true},
31+ {"tile with no usable area", viewport{frame: v.frame}, Placement{Rect: Rect{W: 50, H: 60}}, false},
32+ {"float with no usable area", viewport{frame: v.frame}, Placement{Rect: Rect{W: 50, H: 60}, Floating: true}, true},
33+ } {
34+ if got := tc.v.shows(tc.p); got != tc.want {
35+ t.Errorf("%s: shows %v, want %v", tc.name, got, tc.want)
36+ }
37+ }
38+}
39+
40+// viewportCore is a core with one 100x80 output whose usable area is
41+// usable, publishing into the returned check: it asserts what each
42+// window's configure and the state snapshot say about its visibility.
43+func viewportCore(t *testing.T, layout func(*ports.Config), usable Rect) (*Core, func(map[WindowID]bool) ports.Scene) {
44+ t.Helper()
45+ cfg := ports.Config{}
46+ cfg.Keyboard.CmdKey = "super"
47+ layout(&cfg)
48+ commands := make(chan ports.ClientCommand, 256)
49+ scenes := make(chan []ports.Scene, 1)
50+ c, err := New(cfg, Channels{Commands: commands, Scenes: scenes}, Options{})
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80})
55+ c.cur().mon.SetUsable(usable)
56+ sent := map[WindowID]ports.ConfigureWindow{}
57+ check := func(want map[WindowID]bool) ports.Scene {
58+ t.Helper()
59+ if err := c.publish(context.Background()); err != nil {
60+ t.Fatal(err)
61+ }
62+ scene := (<-scenes)[0]
63+ for len(commands) > 0 {
64+ if v, ok := (<-commands).(ports.ConfigureWindow); ok {
65+ sent[v.ID] = v
66+ }
67+ }
68+ state := map[WindowID]bool{}
69+ for _, s := range c.state().Windows {
70+ state[s.ID] = s.Visible
71+ }
72+ for id, vis := range want {
73+ if v := sent[id]; v.Visible != vis {
74+ t.Fatalf("window %d: configure %+v, want visible %v", id, v, vis)
75+ }
76+ if state[id] != vis {
77+ t.Fatalf("window %d: state visible %v, want %v", id, state[id], vis)
78+ }
79+ }
80+ return scene
81+ }
82+ return c, check
83+}
84+
85+// The next cascade band starts right under a bottom panel: its windows are
86+// not seen, so they are told they are invisible (and throttled), while the
87+// scene still carries them.
88+func TestConfigureVisibleCascadeUnderPanel(t *testing.T) {
89+ c, check := viewportCore(t, func(cfg *ports.Config) {
90+ cfg.Layout.Overflow = "cascade"
91+ cfg.Layout.MaxColumns = 2
92+ }, Rect{W: 100, H: 60})
93+ m := c.cur().mon
94+ w := m.Current()
95+ for id := WindowID(1); id <= 3; id++ {
96+ m.AddWindow(id)
97+ }
98+ w.FocusID(1)
99+ scene := check(map[WindowID]bool{1: true, 2: true, 3: false})
100+ found := false
101+ for _, sw := range scene.Windows {
102+ found = found || sw.ID == 3
103+ }
104+ if !found {
105+ t.Fatal("window under the panel left the scene")
106+ }
107+ // It is cut at the panel and takes no pointer there.
108+ if scene.TileInset != (ports.Insets{Bottom: 20}) {
109+ t.Fatalf("tile inset %+v, want the panel's", scene.TileInset)
110+ }
111+ if id, _, _ := c.hit(10, 70); id != 0 {
112+ t.Fatalf("pointer over the panel went to window %d", id)
113+ }
114+ if id, _, _ := c.hit(10, 30); id != 1 {
115+ t.Fatalf("pointer over window 1 went to %d", id)
116+ }
117+ // Not on screen: its popups are not either, and it loses the pointer.
118+ c.popups[10] = &popupState{id: 10, parent: 3, mapped: true, rect: Rect{W: 5, H: 5}}
119+ c.popupOrder = append(c.popupOrder, 10)
120+ c.pointer = 3
121+ check(nil)
122+ if c.visible(3) || c.visible(10) || c.pointer != 0 {
123+ t.Fatalf("tile under the panel: visible %v, popup %v, pointer %d", c.visible(3), c.visible(10), c.pointer)
124+ }
125+ delete(c.popups, 10)
126+ c.popupOrder = slices.DeleteFunc(c.popupOrder, func(id WindowID) bool { return id == 10 })
127+ // The overview lays previews over the whole output: no inset.
128+ m.ToggleOverview()
129+ if s := check(nil); s.TileInset != (ports.Insets{}) {
130+ t.Fatalf("tile inset %+v in the overview", s.TileInset)
131+ }
132+ m.ToggleOverview()
133+ // Its band scrolled in, the first band is under nothing but off screen.
134+ w.FocusID(3)
135+ check(map[WindowID]bool{1: false, 2: false, 3: true})
136+ // Scrolled back, its band is under the panel again.
137+ w.FocusID(1)
138+ check(map[WindowID]bool{1: true, 3: false})
139+ // Fullscreen covers the panel: always seen.
140+ w.FocusID(3)
141+ w.ToggleFullscreen()
142+ check(map[WindowID]bool{3: true})
143+}
144+
145+// A tile half under a bottom panel: its lines, its pointer bounds (warp,
146+// constraints) and its draw all stop at the panel, and the renderer's tile
147+// rule matches core's.
148+func TestTileStopsAtPanel(t *testing.T) {
149+ c, check := viewportCore(t, func(cfg *ports.Config) {
150+ cfg.Layout.MaxColumns = 2
151+ cfg.Border.Width = 2
152+ }, Rect{W: 100, H: 60})
153+ m := c.cur().mon
154+ m.AddWindow(1)
155+ m.AddWindow(3)
156+ m.Current().ToggleWindowStash() // 3 floats
157+ m.AddWindow(2)
158+ m.Current().FocusID(1)
159+ scene := check(map[WindowID]bool{1: true, 2: true, 3: true})
160+ // Move tile 1 across the panel line, as a slide would.
161+ sc := c.cur()
162+ sc.shown = slices.Clone(sc.shownLayout())
163+ for i := range sc.shown {
164+ if sc.shown[i].ID == 1 {
165+ sc.shown[i].Rect.Y = 30
166+ }
167+ }
168+ if r, ok := c.shownClientRect(1); !ok || r.Y+r.H > 60 {
169+ t.Fatalf("pointer bounds %+v %v reach over the panel", r, ok)
170+ }
171+ for _, s := range separators(sc.shown, 2, 0, m.viewport(), true) {
172+ if s.Window == 0 && s.Rect.Y+s.Rect.H > 60 {
173+ t.Fatalf("tile line over the panel: %+v", s)
174+ }
175+ }
176+ view := m.viewport()
177+ for k, sw := range scene.Windows {
178+ if sw.Popup {
179+ continue
180+ }
181+ if p := sc.settledLayout[k]; sw.Tile() != (view.area(p) == view.usable) {
182+ t.Fatalf("window %d: renderer tile %v, core area %+v", sw.ID, sw.Tile(), view.area(p))
183+ }
184+ }
185+}
186+
187+// Panels that leave no room draw no tile, a leaving one included, and the
188+// focus pulse has nothing to show.
189+func TestNoUsableAreaDrawsNoTile(t *testing.T) {
190+ c, check := viewportCore(t, func(cfg *ports.Config) { cfg.Layout.MaxColumns = 1 }, Rect{})
191+ c.cur().mon.AddWindow(1)
192+ c.pulse.target = 1
193+ scene := check(map[WindowID]bool{1: false})
194+ if scene.Draws(scene.Windows[0]) || c.pulse.drawable {
195+ t.Fatalf("tile drawn %v, pulse drawable %v with no usable area", scene.Draws(scene.Windows[0]), c.pulse.drawable)
196+ }
197+ sc := c.cur()
198+ sc.shown = slices.Clone(sc.shownLayout())
199+ sc.shown[0].Leaving = true
200+ scene, _, err := c.sceneFor(context.Background(), 0, sc, nil, nil, 0)
201+ if err != nil {
202+ t.Fatal(err)
203+ }
204+ if scene.Draws(scene.Windows[0]) {
205+ t.Fatalf("leaving tile drawn with no usable area: %+v", scene.Windows[0])
206+ }
207+}
208+
209+// A sized workspace with a panel: the scene's tile area is the workspace's
210+// usable area, inside its viewport.
211+func TestTileAreaSizedWorkspace(t *testing.T) {
212+ c, check := viewportCore(t, func(cfg *ports.Config) { cfg.Layout.MaxColumns = 1 }, Rect{W: 100, H: 70})
213+ m := c.cur().mon
214+ m.Current().SetSize(60, 80)
215+ m.AddWindow(1)
216+ scene := check(map[WindowID]bool{1: true})
217+ a, ok := scene.TileArea()
218+ if u := m.Current().Usable; !ok || a != u || u == m.Current().Output {
219+ t.Fatalf("tile area %+v %v, want usable %+v inside frame %+v", a, ok, u, m.Current().Output)
220+ }
221+}
222+
223+// A column scrolled under a left panel is not seen.
224+func TestConfigureVisibleScrollUnderSidePanel(t *testing.T) {
225+ c, check := viewportCore(t, func(cfg *ports.Config) {
226+ cfg.Layout.MaxColumns = 1
227+ }, Rect{X: 20, W: 80, H: 80})
228+ m := c.cur().mon
229+ m.AddWindow(1)
230+ m.AddWindow(2)
231+ if r := m.Layout()[0].Rect; r.X >= 20 || r.X+r.W <= 0 {
232+ t.Fatalf("column 1 at %+v, want partly under the panel", r)
233+ }
234+ check(map[WindowID]bool{1: false, 2: true})
235+}
M internal/ports/scene.go +44 -5
@@ -27,6 +27,13 @@ type Scene struct {
2727 // in logical output coordinates. Zero inherits the full output. Layers
2828 // remain output-wide; overview previews do not use this clip.
2929 WorkspaceClip Rect
30+ // TileInset is what exclusive layers (panels) and a workspace size
31+ // override take from the output's edges: tiles (SceneWindow.Tile) and
32+ // their lines are drawn only in what is left, the usable area
33+ // (TileArea). A tile is never drawn over a panel, whatever its layer;
34+ // insets covering the output leave no tile drawn. Zero: the usable
35+ // area is the whole output (and the overview, capture scenes).
36+ TileInset Insets
3037 // Dim darkens the background, bottom layers and tiles (tile lines
3138 // included) inside WorkspaceClip when set, with black at this opacity,
3239 // 0 to 1, under the first
@@ -74,7 +81,7 @@ func (s Scene) SameAs(o Scene) bool {
7481 s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight ||
7582 s.Scale != o.Scale || s.Transform != o.Transform || s.Off != o.Off ||
7683 s.Background != o.Background || s.Border != o.Border ||
77- s.WorkspaceClip != o.WorkspaceClip || s.Dim != o.Dim || s.DimBehind != o.DimBehind {
84+ s.WorkspaceClip != o.WorkspaceClip || s.TileInset != o.TileInset || s.Dim != o.Dim || s.DimBehind != o.DimBehind {
7885 return false
7986 }
8087 if s.CaptureScene != nil || o.CaptureScene != nil {
@@ -97,12 +104,10 @@ func (s Scene) SameAs(o Scene) bool {
97104
98105 // Shows reports whether the scene draws the surface of id: only its
99106 // content changes need a new frame. A window scrolled off the output is
100-// not drawn. WorkspaceClip also excludes off-viewport windows; OverLayers
101-// popups remain output-wide.
107+// not drawn (Draws).
102108 func (s Scene) Shows(id WindowID) bool {
103109 for _, w := range s.Windows {
104- if w.ID == id && !w.Hidden && w.Rect.Overlaps(Rect{W: s.OutputWidth, H: s.OutputHeight}) &&
105- (w.OverLayers || s.WorkspaceClip == (Rect{}) || w.Rect.Overlaps(s.WorkspaceClip)) {
110+ if w.ID == id && s.Draws(w) {
106111 return true
107112 }
108113 }
@@ -114,6 +119,33 @@ func (s Scene) Shows(id WindowID) bool {
114119 return false
115120 }
116121
122+// Draws reports whether any of w is drawn: not hidden, on the output,
123+// inside WorkspaceClip (OverLayers popups remain output-wide) and, for a
124+// tile, inside TileArea.
125+func (s Scene) Draws(w SceneWindow) bool {
126+ if w.Hidden || !w.Rect.Overlaps(Rect{W: s.OutputWidth, H: s.OutputHeight}) {
127+ return false
128+ }
129+ if !w.OverLayers && s.WorkspaceClip != (Rect{}) && !w.Rect.Overlaps(s.WorkspaceClip) {
130+ return false
131+ }
132+ if a, ok := s.TileArea(); ok && w.Tile() {
133+ return w.Rect.Overlaps(a)
134+ }
135+ return true
136+}
137+
138+// TileArea is where tiles are drawn: the output minus TileInset, empty
139+// when the panels cover it. ok is false when TileInset is zero: tiles are
140+// not clipped beyond the output and WorkspaceClip.
141+func (s Scene) TileArea() (r Rect, ok bool) {
142+ in := s.TileInset
143+ if in == (Insets{}) {
144+ return Rect{}, false
145+ }
146+ return Rect{X: in.Left, Y: in.Top, W: max(s.OutputWidth-in.Left-in.Right, 0), H: max(s.OutputHeight-in.Top-in.Bottom, 0)}, true
147+}
148+
117149 // Border carries the window border style; colors are #rrggbb, "" skips
118150 // drawing. Width is in logical pixels.
119151 type Border struct {
@@ -180,3 +212,10 @@ type SceneWindow struct {
180212 // overlay layers. Window popups stay with the windows, under them.
181213 OverLayers bool
182214 }
215+
216+// Tile reports whether w is a tile: laid out in the usable area and
217+// clipped to Scene.TileArea. Floats, fullscreen windows, overview previews
218+// and popups are placed against the whole viewport.
219+func (w SceneWindow) Tile() bool {
220+ return !w.Floating && !w.Fullscreen && !w.Popup && w.Preview == 0
221+}
@@ -27,6 +27,13 @@ type Scene struct {
27 // in logical output coordinates. Zero inherits the full output. Layers27 // in logical output coordinates. Zero inherits the full output. Layers
28 // remain output-wide; overview previews do not use this clip.28 // remain output-wide; overview previews do not use this clip.
29 WorkspaceClip Rect29 WorkspaceClip Rect
30+ // TileInset is what exclusive layers (panels) and a workspace size
31+ // override take from the output's edges: tiles (SceneWindow.Tile) and
32+ // their lines are drawn only in what is left, the usable area
33+ // (TileArea). A tile is never drawn over a panel, whatever its layer;
34+ // insets covering the output leave no tile drawn. Zero: the usable
35+ // area is the whole output (and the overview, capture scenes).
36+ TileInset Insets
30 // Dim darkens the background, bottom layers and tiles (tile lines37 // Dim darkens the background, bottom layers and tiles (tile lines
31 // included) inside WorkspaceClip when set, with black at this opacity,38 // included) inside WorkspaceClip when set, with black at this opacity,
32 // 0 to 1, under the first39 // 0 to 1, under the first
@@ -74,7 +81,7 @@ func (s Scene) SameAs(o Scene) bool {
74 s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight ||81 s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight ||
75 s.Scale != o.Scale || s.Transform != o.Transform || s.Off != o.Off ||82 s.Scale != o.Scale || s.Transform != o.Transform || s.Off != o.Off ||
76 s.Background != o.Background || s.Border != o.Border ||83 s.Background != o.Background || s.Border != o.Border ||
77- s.WorkspaceClip != o.WorkspaceClip || s.Dim != o.Dim || s.DimBehind != o.DimBehind {84+ s.WorkspaceClip != o.WorkspaceClip || s.TileInset != o.TileInset || s.Dim != o.Dim || s.DimBehind != o.DimBehind {
78 return false85 return false
79 }86 }
80 if s.CaptureScene != nil || o.CaptureScene != nil {87 if s.CaptureScene != nil || o.CaptureScene != nil {
@@ -97,12 +104,10 @@ func (s Scene) SameAs(o Scene) bool {
97 104
98 // Shows reports whether the scene draws the surface of id: only its105 // Shows reports whether the scene draws the surface of id: only its
99 // content changes need a new frame. A window scrolled off the output is106 // content changes need a new frame. A window scrolled off the output is
100-// not drawn. WorkspaceClip also excludes off-viewport windows; OverLayers107+// not drawn (Draws).
101-// popups remain output-wide.
102 func (s Scene) Shows(id WindowID) bool {108 func (s Scene) Shows(id WindowID) bool {
103 for _, w := range s.Windows {109 for _, w := range s.Windows {
104- if w.ID == id && !w.Hidden && w.Rect.Overlaps(Rect{W: s.OutputWidth, H: s.OutputHeight}) &&110+ if w.ID == id && s.Draws(w) {
105- (w.OverLayers || s.WorkspaceClip == (Rect{}) || w.Rect.Overlaps(s.WorkspaceClip)) {
106 return true111 return true
107 }112 }
108 }113 }
@@ -114,6 +119,33 @@ func (s Scene) Shows(id WindowID) bool {
114 return false119 return false
115 }120 }
116 121
122+// Draws reports whether any of w is drawn: not hidden, on the output,
123+// inside WorkspaceClip (OverLayers popups remain output-wide) and, for a
124+// tile, inside TileArea.
125+func (s Scene) Draws(w SceneWindow) bool {
126+ if w.Hidden || !w.Rect.Overlaps(Rect{W: s.OutputWidth, H: s.OutputHeight}) {
127+ return false
128+ }
129+ if !w.OverLayers && s.WorkspaceClip != (Rect{}) && !w.Rect.Overlaps(s.WorkspaceClip) {
130+ return false
131+ }
132+ if a, ok := s.TileArea(); ok && w.Tile() {
133+ return w.Rect.Overlaps(a)
134+ }
135+ return true
136+}
137+
138+// TileArea is where tiles are drawn: the output minus TileInset, empty
139+// when the panels cover it. ok is false when TileInset is zero: tiles are
140+// not clipped beyond the output and WorkspaceClip.
141+func (s Scene) TileArea() (r Rect, ok bool) {
142+ in := s.TileInset
143+ if in == (Insets{}) {
144+ return Rect{}, false
145+ }
146+ return Rect{X: in.Left, Y: in.Top, W: max(s.OutputWidth-in.Left-in.Right, 0), H: max(s.OutputHeight-in.Top-in.Bottom, 0)}, true
147+}
148+
117 // Border carries the window border style; colors are #rrggbb, "" skips149 // Border carries the window border style; colors are #rrggbb, "" skips
118 // drawing. Width is in logical pixels.150 // drawing. Width is in logical pixels.
119 type Border struct {151 type Border struct {
@@ -180,3 +212,10 @@ type SceneWindow struct {
180 // overlay layers. Window popups stay with the windows, under them.212 // overlay layers. Window popups stay with the windows, under them.
181 OverLayers bool213 OverLayers bool
182 }214 }
215+
216+// Tile reports whether w is a tile: laid out in the usable area and
217+// clipped to Scene.TileArea. Floats, fullscreen windows, overview previews
218+// and popups are placed against the whole viewport.
219+func (w SceneWindow) Tile() bool {
220+ return !w.Floating && !w.Fullscreen && !w.Popup && w.Preview == 0
221+}
M internal/ports/scene_same_test.go +2 -1
@@ -48,6 +48,7 @@ func TestSceneSameAs(t *testing.T) {
4848 "scale": func(s *Scene) { s.Scale = 2 },
4949 "off": func(s *Scene) { s.Off = true },
5050 "clip": func(s *Scene) { s.WorkspaceClip.W = 1 },
51+ "tile inset": func(s *Scene) { s.TileInset.Bottom = 1 },
5152 "dim": func(s *Scene) { s.Dim = 0.5 },
5253 "dim behind": func(s *Scene) { s.DimBehind = true },
5354 "separator": func(s *Scene) { s.Separators[0].Active = true },
@@ -82,7 +83,7 @@ func TestSceneSameAs(t *testing.T) {
8283 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
8384 // update Scene.SameAs, then this count.
8485 func TestSceneSameAsCoversEveryField(t *testing.T) {
85- const fields = 20
86+ const fields = 21
8687 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
8788 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
8889 }
@@ -48,6 +48,7 @@ func TestSceneSameAs(t *testing.T) {
48 "scale": func(s *Scene) { s.Scale = 2 },48 "scale": func(s *Scene) { s.Scale = 2 },
49 "off": func(s *Scene) { s.Off = true },49 "off": func(s *Scene) { s.Off = true },
50 "clip": func(s *Scene) { s.WorkspaceClip.W = 1 },50 "clip": func(s *Scene) { s.WorkspaceClip.W = 1 },
51+ "tile inset": func(s *Scene) { s.TileInset.Bottom = 1 },
51 "dim": func(s *Scene) { s.Dim = 0.5 },52 "dim": func(s *Scene) { s.Dim = 0.5 },
52 "dim behind": func(s *Scene) { s.DimBehind = true },53 "dim behind": func(s *Scene) { s.DimBehind = true },
53 "separator": func(s *Scene) { s.Separators[0].Active = true },54 "separator": func(s *Scene) { s.Separators[0].Active = true },
@@ -82,7 +83,7 @@ func TestSceneSameAs(t *testing.T) {
82 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:83 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
83 // update Scene.SameAs, then this count.84 // update Scene.SameAs, then this count.
84 func TestSceneSameAsCoversEveryField(t *testing.T) {85 func TestSceneSameAsCoversEveryField(t *testing.T) {
85- const fields = 2086+ const fields = 21
86 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),87 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
87 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")88 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
88 }89 }
M internal/ports/types.go +8 -0
@@ -24,6 +24,14 @@ func (r Rect) Overlaps(o Rect) bool {
2424 r.X < o.X+o.W && o.X < r.X+r.W && r.Y < o.Y+o.H && o.Y < r.Y+r.H
2525 }
2626
27+// Contains reports whether o lies wholly inside r.
28+func (r Rect) Contains(o Rect) bool {
29+ return o.X >= r.X && o.Y >= r.Y && o.X+o.W <= r.X+r.W && o.Y+o.H <= r.Y+r.H
30+}
31+
32+// Insets are distances from the four edges of an area.
33+type Insets struct{ Top, Right, Bottom, Left int }
34+
2735 // Inset shrinks r by b on each side in s.
2836 func (r Rect) Inset(s Sides, b int) Rect {
2937 b = min(max(b, 0), r.W/2, r.H/2)
@@ -24,6 +24,14 @@ func (r Rect) Overlaps(o Rect) bool {
24 r.X < o.X+o.W && o.X < r.X+r.W && r.Y < o.Y+o.H && o.Y < r.Y+r.H24 r.X < o.X+o.W && o.X < r.X+r.W && r.Y < o.Y+o.H && o.Y < r.Y+r.H
25 }25 }
26 26
27+// Contains reports whether o lies wholly inside r.
28+func (r Rect) Contains(o Rect) bool {
29+ return o.X >= r.X && o.Y >= r.Y && o.X+o.W <= r.X+r.W && o.Y+o.H <= r.Y+r.H
30+}
31+
32+// Insets are distances from the four edges of an area.
33+type Insets struct{ Top, Right, Bottom, Left int }
34+
27 // Inset shrinks r by b on each side in s.35 // Inset shrinks r by b on each side in s.
28 func (r Rect) Inset(s Sides, b int) Rect {36 func (r Rect) Inset(s Sides, b int) Rect {
29 b = min(max(b, 0), r.W/2, r.H/2)37 b = min(max(b, 0), r.W/2, r.H/2)
M internal/ports/workspace_clip_test.go +37 -0
@@ -16,3 +16,40 @@ func TestSceneShowsWorkspaceClip(t *testing.T) {
1616 t.Fatal("inherited workspace excluded surface")
1717 }
1818 }
19+
20+// A tile under a panel (outside TileArea) is not drawn; a float, a
21+// fullscreen window, a preview or a popup there still is. Panels covering
22+// the whole output leave no tile drawn.
23+func TestSceneShowsTileInset(t *testing.T) {
24+ under := Rect{Y: 80, W: 50, H: 80}
25+ s := Scene{OutputWidth: 100, OutputHeight: 100, TileInset: Insets{Bottom: 20}, Windows: []SceneWindow{
26+ {ID: 1, Rect: Rect{W: 50, H: 80}},
27+ {ID: 2, Rect: under},
28+ {ID: 3, Rect: under, Floating: true},
29+ {ID: 4, Rect: under, Fullscreen: true},
30+ {ID: 5, Rect: under, Preview: 0.5},
31+ {ID: 6, Rect: under, Popup: true},
32+ }}
33+ for id, want := range map[WindowID]bool{1: true, 2: false, 3: true, 4: true, 5: true, 6: true} {
34+ if got := s.Shows(id); got != want {
35+ t.Errorf("window %d shown %v, want %v", id, got, want)
36+ }
37+ }
38+ s.TileInset = Insets{Right: 100, Bottom: 100}
39+ if s.Shows(1) || !s.Shows(3) {
40+ t.Fatalf("no room for tiles: tile %v, float %v", s.Shows(1), s.Shows(3))
41+ }
42+ s.TileInset = Insets{}
43+ if !s.Shows(2) {
44+ t.Fatal("tile with no panel not shown")
45+ }
46+}
47+
48+func TestRectContains(t *testing.T) {
49+ r := Rect{X: 10, Y: 10, W: 20, H: 20}
50+ for o, want := range map[Rect]bool{r: true, {X: 15, Y: 15, W: 5, H: 5}: true, {X: 9, Y: 10, W: 5, H: 5}: false, {X: 25, Y: 25, W: 6, H: 5}: false} {
51+ if got := r.Contains(o); got != want {
52+ t.Errorf("%+v contains %+v = %v, want %v", r, o, got, want)
53+ }
54+ }
55+}
@@ -16,3 +16,40 @@ func TestSceneShowsWorkspaceClip(t *testing.T) {
16 t.Fatal("inherited workspace excluded surface")16 t.Fatal("inherited workspace excluded surface")
17 }17 }
18 }18 }
19+
20+// A tile under a panel (outside TileArea) is not drawn; a float, a
21+// fullscreen window, a preview or a popup there still is. Panels covering
22+// the whole output leave no tile drawn.
23+func TestSceneShowsTileInset(t *testing.T) {
24+ under := Rect{Y: 80, W: 50, H: 80}
25+ s := Scene{OutputWidth: 100, OutputHeight: 100, TileInset: Insets{Bottom: 20}, Windows: []SceneWindow{
26+ {ID: 1, Rect: Rect{W: 50, H: 80}},
27+ {ID: 2, Rect: under},
28+ {ID: 3, Rect: under, Floating: true},
29+ {ID: 4, Rect: under, Fullscreen: true},
30+ {ID: 5, Rect: under, Preview: 0.5},
31+ {ID: 6, Rect: under, Popup: true},
32+ }}
33+ for id, want := range map[WindowID]bool{1: true, 2: false, 3: true, 4: true, 5: true, 6: true} {
34+ if got := s.Shows(id); got != want {
35+ t.Errorf("window %d shown %v, want %v", id, got, want)
36+ }
37+ }
38+ s.TileInset = Insets{Right: 100, Bottom: 100}
39+ if s.Shows(1) || !s.Shows(3) {
40+ t.Fatalf("no room for tiles: tile %v, float %v", s.Shows(1), s.Shows(3))
41+ }
42+ s.TileInset = Insets{}
43+ if !s.Shows(2) {
44+ t.Fatal("tile with no panel not shown")
45+ }
46+}
47+
48+func TestRectContains(t *testing.T) {
49+ r := Rect{X: 10, Y: 10, W: 20, H: 20}
50+ for o, want := range map[Rect]bool{r: true, {X: 15, Y: 15, W: 5, H: 5}: true, {X: 9, Y: 10, W: 5, H: 5}: false, {X: 25, Y: 25, W: 6, H: 5}: false} {
51+ if got := r.Contains(o); got != want {
52+ t.Errorf("%+v contains %+v = %v, want %v", r, o, got, want)
53+ }
54+ }
55+}

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