detached 5077157

refactor(render): carry panel insets as Scene.TileInset

TileInset replaces TileClip: zero means no panel, and insets covering
the output mean no room for tiles, so core no longer hides tiles by
hand. Scene.Draws and Scene.TileArea own the rule. The renderer cuts
tile lines and damage at the tile area too, the overlay plane skips a
tile wholly under a panel instead of picking it, and Rect.Contains
replaces two copies of the containment check. The focus pulse needs
room to be drawn.
commit
50771574b1d96598c25abe70e2c7e25bb14ddfd7
author
brice · · 2026-10-08 14:49 UTC
parent
e06b149
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +179 -70
M internal/adapters/drm/overlay.go +7 -4
@@ -79,6 +79,11 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
7979 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
8080 continue
8181 }
82+ if a, ok := s.TileArea(); ok && w.Tile() && !w.Rect.Overlaps(a) {
83+ // A tile wholly under a panel is not drawn: neither a
84+ // candidate nor over one.
85+ continue
86+ }
8287 if w.Popup {
8388 // Popups draw after every window, wherever they are listed:
8489 // checked once the candidate is known.
@@ -148,13 +153,11 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
148153 }
149154 }
150155 }
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) {
156+ if clip := s.WorkspaceClip; clip != (ports.Rect{}) && !clip.Contains(pick.Rect) {
153157 // The plane cannot apply the workspace's logical viewport crop.
154158 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "workspace_clip"
155159 }
156- if clip := s.TileClip; pick.Tile() && clip != (ports.Rect{}) &&
157- (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) {
160+ if a, ok := s.TileArea(); ok && pick.Tile() && !a.Contains(pick.Rect) {
158161 // Nor the cut of a tile at a panel.
159162 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "tile_clip"
160163 }
@@ -79,6 +79,11 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
79 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {79 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
80 continue80 continue
81 }81 }
82+ if a, ok := s.TileArea(); ok && w.Tile() && !w.Rect.Overlaps(a) {
83+ // A tile wholly under a panel is not drawn: neither a
84+ // candidate nor over one.
85+ continue
86+ }
82 if w.Popup {87 if w.Popup {
83 // Popups draw after every window, wherever they are listed:88 // Popups draw after every window, wherever they are listed:
84 // checked once the candidate is known.89 // checked once the candidate is known.
@@ -148,13 +153,11 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
148 }153 }
149 }154 }
150 }155 }
151- if clip := s.WorkspaceClip; clip != (ports.Rect{}) &&156+ 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.157 // The plane cannot apply the workspace's logical viewport crop.
154 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "workspace_clip"158 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "workspace_clip"
155 }159 }
156- if clip := s.TileClip; pick.Tile() && clip != (ports.Rect{}) &&160+ if a, ok := s.TileArea(); ok && pick.Tile() && !a.Contains(pick.Rect) {
157- (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) {
158 // Nor the cut of a tile at a panel.161 // Nor the cut of a tile at a panel.
159 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "tile_clip"162 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "tile_clip"
160 }163 }
M internal/adapters/drm/overlay_test.go +12 -6
@@ -63,17 +63,23 @@ func TestOverlayWorkspaceClip(t *testing.T) {
6363 }
6464 }
6565
66-// A tile cut by a panel (TileClip) is composed: the plane would show it
67-// whole, over the panel.
68-func TestOverlayTileClip(t *testing.T) {
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) {
6970 s, contents := overlayScene()
70- s.TileClip = ports.Rect{W: 200, H: 80}
71+ s.TileInset = ports.Insets{Bottom: 20}
7172 if _, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "tile_clip" {
7273 t.Fatalf("tile under a panel on the overlay: %q", reason)
7374 }
74- s.TileClip = ports.Rect{W: 200, H: 100}
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}}
7581 if win, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "" || win.ID != 2 {
76- t.Fatalf("tile inside the clip: %v %q", win.ID, reason)
82+ t.Fatalf("visible tile with one under the panel: %v %q", win.ID, reason)
7783 }
7884 }
7985
@@ -63,17 +63,23 @@ func TestOverlayWorkspaceClip(t *testing.T) {
63 }63 }
64 }64 }
65 65
66-// A tile cut by a panel (TileClip) is composed: the plane would show it66+// A tile cut by a panel (TileInset) is composed: the plane would show it
67-// whole, over the panel.67+// whole, over the panel. A tile wholly under the panel is not drawn, so it
68-func TestOverlayTileClip(t *testing.T) {68+// neither takes the plane nor blocks the visible tile.
69+func TestOverlayTileInset(t *testing.T) {
69 s, contents := overlayScene()70 s, contents := overlayScene()
70- s.TileClip = ports.Rect{W: 200, H: 80}71+ s.TileInset = ports.Insets{Bottom: 20}
71 if _, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "tile_clip" {72 if _, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "tile_clip" {
72 t.Fatalf("tile under a panel on the overlay: %q", reason)73 t.Fatalf("tile under a panel on the overlay: %q", reason)
73 }74 }
74- s.TileClip = ports.Rect{W: 200, H: 100}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}}
75 if win, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "" || win.ID != 2 {81 if win, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "" || win.ID != 2 {
76- t.Fatalf("tile inside the clip: %v %q", win.ID, reason)82+ t.Fatalf("visible tile with one under the panel: %v %q", win.ID, reason)
77 }83 }
78 }84 }
79 85
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.TileClip != cur.TileClip || 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.TileClip != cur.TileClip || 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 +1 -1
@@ -98,7 +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 clip": func(s *ports.Scene) { s.TileClip = ports.Rect{W: 10, H: 10} },
101+ "tile inset": func(s *ports.Scene) { s.TileInset = ports.Insets{Bottom: 10} },
102102 "dim": func(s *ports.Scene) { s.Dim = 0.5 },
103103 "dim behind": func(s *ports.Scene) { s.DimBehind = true },
104104 "drop hints": func(s *ports.Scene) { s.DropHints = []ports.Rect{{W: 5, H: 5}} },
@@ -98,7 +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 clip": func(s *ports.Scene) { s.TileClip = ports.Rect{W: 10, H: 10} },101+ "tile inset": func(s *ports.Scene) { s.TileInset = ports.Insets{Bottom: 10} },
102 "dim": func(s *ports.Scene) { s.Dim = 0.5 },102 "dim": func(s *ports.Scene) { s.Dim = 0.5 },
103 "dim behind": func(s *ports.Scene) { s.DimBehind = true },103 "dim behind": func(s *ports.Scene) { s.DimBehind = true },
104 "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 +17 -8
@@ -28,7 +28,8 @@ type sceneWalk struct {
2828 // it (premultiplied); 1 otherwise.
2929 alpha float32
3030 bounds image.Rectangle
31- // tileBounds is bounds cut to Scene.TileClip: where tiles draw.
31+ // tileBounds is bounds cut to Scene.TileArea: where tiles and their
32+ // lines draw.
3233 tileBounds image.Rectangle
3334 draws []draw
3435 }
@@ -51,9 +52,8 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
5152 w.bounds = w.bounds.Intersect(w.physRect(c.X, c.Y, c.W, c.H))
5253 }
5354 w.tileBounds = w.bounds
54- if s.TileClip != (ports.Rect{}) {
55- c := s.TileClip
56- w.tileBounds = w.bounds.Intersect(w.physRect(c.X, c.Y, c.W, c.H))
55+ if a, ok := s.TileArea(); ok {
56+ w.tileBounds = w.bounds.Intersect(w.physRect(a.X, a.Y, a.W, a.H))
5757 }
5858 w.windows()
5959 w.popups(false)
@@ -214,7 +214,7 @@ func (w *sceneWalk) windows() {
214214 continue
215215 }
216216 if opensFloats(win) && !tileLines {
217- w.separators(0)
217+ w.tileSeparators()
218218 tileLines = true
219219 if w.s.Dim > 0 && !w.s.DimBehind {
220220 w.dim(w.bounds, w.s.Dim)
@@ -228,7 +228,7 @@ func (w *sceneWalk) windows() {
228228 w.alpha = 1 - float32(max(0, win.Fade))
229229 windowBounds := w.bounds
230230 if win.Tile() {
231- // A tile never draws over a panel (Scene.TileClip).
231+ // A tile never draws over a panel (Scene.TileArea).
232232 w.bounds = w.tileBounds
233233 }
234234 // Content sits inside the border; core sized the client to match.
@@ -239,7 +239,8 @@ func (w *sceneWalk) windows() {
239239 c := win.Rect.Inset(inset, b)
240240 body := w.physRect(c.X, c.Y, c.W, c.H)
241241 content := w.contents[win.ID]
242- 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))
243244 // Until its first buffer, a window shows the background: no flash.
244245 w.fill(body, parseColor(w.s.Background))
245246 if !content.Empty() {
@@ -264,10 +265,18 @@ func (w *sceneWalk) windows() {
264265 w.alpha, w.bounds = 1, windowBounds
265266 }
266267 if !tileLines {
267- w.separators(0)
268+ w.tileSeparators()
268269 }
269270 }
270271
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+
271280 // separators draws a window's border lines in order, active lines over
272281 // inactive ones.
273282 func (w *sceneWalk) separators(id ports.WindowID) {
@@ -28,7 +28,8 @@ 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- // tileBounds is bounds cut to Scene.TileClip: where tiles draw.31+ // tileBounds is bounds cut to Scene.TileArea: where tiles and their
32+ // lines draw.
32 tileBounds image.Rectangle33 tileBounds image.Rectangle
33 draws []draw34 draws []draw
34 }35 }
@@ -51,9 +52,8 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
51 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))
52 }53 }
53 w.tileBounds = w.bounds54 w.tileBounds = w.bounds
54- if s.TileClip != (ports.Rect{}) {55+ if a, ok := s.TileArea(); ok {
55- c := s.TileClip56+ w.tileBounds = w.bounds.Intersect(w.physRect(a.X, a.Y, a.W, a.H))
56- w.tileBounds = w.bounds.Intersect(w.physRect(c.X, c.Y, c.W, c.H))
57 }57 }
58 w.windows()58 w.windows()
59 w.popups(false)59 w.popups(false)
@@ -214,7 +214,7 @@ func (w *sceneWalk) windows() {
214 continue214 continue
215 }215 }
216 if opensFloats(win) && !tileLines {216 if opensFloats(win) && !tileLines {
217- w.separators(0)217+ w.tileSeparators()
218 tileLines = true218 tileLines = true
219 if w.s.Dim > 0 && !w.s.DimBehind {219 if w.s.Dim > 0 && !w.s.DimBehind {
220 w.dim(w.bounds, w.s.Dim)220 w.dim(w.bounds, w.s.Dim)
@@ -228,7 +228,7 @@ func (w *sceneWalk) windows() {
228 w.alpha = 1 - float32(max(0, win.Fade))228 w.alpha = 1 - float32(max(0, win.Fade))
229 windowBounds := w.bounds229 windowBounds := w.bounds
230 if win.Tile() {230 if win.Tile() {
231- // A tile never draws over a panel (Scene.TileClip).231+ // A tile never draws over a panel (Scene.TileArea).
232 w.bounds = w.tileBounds232 w.bounds = w.tileBounds
233 }233 }
234 // Content sits inside the border; core sized the client to match.234 // Content sits inside the border; core sized the client to match.
@@ -239,7 +239,8 @@ func (w *sceneWalk) windows() {
239 c := win.Rect.Inset(inset, b)239 c := win.Rect.Inset(inset, b)
240 body := w.physRect(c.X, c.Y, c.W, c.H)240 body := w.physRect(c.X, c.Y, c.W, c.H)
241 content := w.contents[win.ID]241 content := w.contents[win.ID]
242- 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))
243 // Until its first buffer, a window shows the background: no flash.244 // Until its first buffer, a window shows the background: no flash.
244 w.fill(body, parseColor(w.s.Background))245 w.fill(body, parseColor(w.s.Background))
245 if !content.Empty() {246 if !content.Empty() {
@@ -264,10 +265,18 @@ func (w *sceneWalk) windows() {
264 w.alpha, w.bounds = 1, windowBounds265 w.alpha, w.bounds = 1, windowBounds
265 }266 }
266 if !tileLines {267 if !tileLines {
267- w.separators(0)268+ w.tileSeparators()
268 }269 }
269 }270 }
270 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+
271 // separators draws a window's border lines in order, active lines over280 // separators draws a window's border lines in order, active lines over
272 // inactive ones.281 // inactive ones.
273 func (w *sceneWalk) separators(id ports.WindowID) {282 func (w *sceneWalk) separators(id ports.WindowID) {
M internal/adapters/vulkan/workspace_clip_test.go +32 -7
@@ -8,9 +8,9 @@ import (
88 "github.com/bnema/neferwl/internal/ports"
99 )
1010
11-// A tile is cut at TileClip, so a bottom layer panel under it stays
12-// visible; a float there is drawn over it.
13-func TestTileClipKeepsBottomPanel(t *testing.T) {
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) {
1414 r, err := New(64, 48)
1515 if err != nil {
1616 t.Skipf("Vulkan unavailable: %v", err)
@@ -20,15 +20,18 @@ func TestTileClipKeepsBottomPanel(t *testing.T) {
2020 green := color.RGBA{0, 255, 0, 255}
2121 blue := color.RGBA{0, 0, 255, 255}
2222 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", TileClip: ports.Rect{W: 64, H: 40},
24- Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}}},
25- Layers: []ports.SceneLayer{{ID: 2, Rect: ports.Rect{Y: 40, W: 64, H: 8}, Layer: ports.LayerBottom}},
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}},
2628 }
2729 if err := render(r, scene, contents); err != nil {
2830 t.Fatal(err)
2931 }
3032 pixels := readPixels(t, r)
31- for point, want := range map[image.Point]color.RGBA{{10, 10}: red, {10, 39}: red, {10, 40}: green, {60, 47}: green} {
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} {
3235 if got := pixels.RGBAAt(point.X, point.Y); got != want {
3336 t.Errorf("pixel %v=%v, want %v", point, got, want)
3437 }
@@ -43,6 +46,28 @@ func TestTileClipKeepsBottomPanel(t *testing.T) {
4346 }
4447 }
4548
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+
4671 func TestWorkspaceClipWindowsPopupsAndLayers(t *testing.T) {
4772 r, err := New(64, 48)
4873 if err != nil {
@@ -8,9 +8,9 @@ import (
8 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
9 )9 )
10 10
11-// A tile is cut at TileClip, so a bottom layer panel under it stays11+// A tile and its lines are cut at TileArea, so a bottom layer panel under
12-// visible; a float there is drawn over it.12+// them stays visible; a float there is drawn over it.
13-func TestTileClipKeepsBottomPanel(t *testing.T) {13+func TestTileInsetKeepsBottomPanel(t *testing.T) {
14 r, err := New(64, 48)14 r, err := New(64, 48)
15 if err != nil {15 if err != nil {
16 t.Skipf("Vulkan unavailable: %v", err)16 t.Skipf("Vulkan unavailable: %v", err)
@@ -20,15 +20,18 @@ func TestTileClipKeepsBottomPanel(t *testing.T) {
20 green := color.RGBA{0, 255, 0, 255}20 green := color.RGBA{0, 255, 0, 255}
21 blue := color.RGBA{0, 0, 255, 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)}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", TileClip: ports.Rect{W: 64, H: 40},23+ scene := ports.Scene{Seq: 1, OutputWidth: 64, OutputHeight: 48, Background: "#000000", TileInset: ports.Insets{Bottom: 8},
24- Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}}},24+ Border: ports.Border{Width: 2, Inactive: "#ffffff"},
25- Layers: []ports.SceneLayer{{ID: 2, Rect: ports.Rect{Y: 40, W: 64, H: 8}, Layer: ports.LayerBottom}},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}},
26 }28 }
27 if err := render(r, scene, contents); err != nil {29 if err := render(r, scene, contents); err != nil {
28 t.Fatal(err)30 t.Fatal(err)
29 }31 }
30 pixels := readPixels(t, r)32 pixels := readPixels(t, r)
31- for point, want := range map[image.Point]color.RGBA{{10, 10}: red, {10, 39}: red, {10, 40}: green, {60, 47}: green} {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} {
32 if got := pixels.RGBAAt(point.X, point.Y); got != want {35 if got := pixels.RGBAAt(point.X, point.Y); got != want {
33 t.Errorf("pixel %v=%v, want %v", point, got, want)36 t.Errorf("pixel %v=%v, want %v", point, got, want)
34 }37 }
@@ -43,6 +46,28 @@ func TestTileClipKeepsBottomPanel(t *testing.T) {
43 }46 }
44 }47 }
45 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+
46 func TestWorkspaceClipWindowsPopupsAndLayers(t *testing.T) {71 func TestWorkspaceClipWindowsPopupsAndLayers(t *testing.T) {
47 r, err := New(64, 48)72 r, err := New(64, 48)
48 if err != nil {73 if err != nil {
M internal/core/core.go +1 -1
@@ -466,7 +466,7 @@ type viewport struct{ frame, usable Rect }
466466 func whole(r Rect) viewport { return viewport{frame: r, usable: r} }
467467
468468 // 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.TileClip), so a cascade band
469+// usable area and is cut at its edge (Scene.TileInset), so a cascade band
470470 // or a column scrolled under a panel is not seen; a float, a fullscreen
471471 // window and an overview preview are placed against the frame.
472472 func (v viewport) area(p Placement) Rect {
@@ -466,7 +466,7 @@ type viewport struct{ frame, usable Rect }
466 func whole(r Rect) viewport { return viewport{frame: r, usable: r} }466 func whole(r Rect) viewport { return viewport{frame: r, usable: r} }
467 467
468 // area is where p can be seen, drawn and pointed at. A tile lives in the468 // 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.TileClip), so a cascade band469+// 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 fullscreen470 // 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.471 // window and an overview preview are placed against the frame.
472 func (v viewport) area(p Placement) Rect {472 func (v viewport) area(p Placement) Rect {
M internal/core/scene.go +10 -11
@@ -22,12 +22,11 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
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- // Tiles stop at the panels; the overview lays out previews over the
26- // whole output. Panels leaving no room at all hide the tiles: a zero
27- // TileClip would not clip.
28- noTiles := !sc.mon.ov.open && view.usable.W*view.usable.H <= 0
29- if !sc.mon.ov.open && view.usable != view.frame {
30- scene.TileClip = view.usable
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}
3130 }
3231 var real map[WindowID]Placement
3332 if sc.mon.ov.open {
@@ -68,9 +67,6 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
6867 // Only the focused output has an activated window.
6968 focused := p.Focused && i == c.focusScreen
7069 sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade}
71- if noTiles && sw.Tile() {
72- sw.Hidden = true
73- }
7470 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
7571 // A scale motion: the content follows the drawn size. A
7672 // card in flight drawn at its size still needs Zoom 1:
@@ -82,7 +78,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
8278 if p.Leaving {
8379 // A window fading out after it closed or hid: drawn, but
8480 // hidden to everything else, its configures included.
85- sw.Hidden = noTiles && sw.Tile()
81+ sw.Hidden = false
8682 scene.Windows = append(scene.Windows, sw)
8783 // A window the workspace still holds (a hidden stash
8884 // window) keeps its last configure: prune would forget it,
@@ -93,7 +89,10 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
9389 }
9490 continue
9591 }
96- if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {
92+ // A window sliding in still waits for its pulse; one with no room
93+ // to be drawn (panels leave no usable area) never gets it.
94+ room := view.area(p)
95+ if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && room.W > 0 && room.H > 0 && p.Preview == 0 && !sc.mon.ov.open {
9796 drawable = true
9897 if p.ID == c.pulse.id {
9998 sw.FocusEffect = pulse
@@ -22,12 +22,11 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
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- // Tiles stop at the panels; the overview lays out previews over the25+ if !sc.mon.ov.open {
26- // whole output. Panels leaving no room at all hide the tiles: a zero26+ // Tiles stop at the panels; the overview lays out previews over
27- // TileClip would not clip.27+ // the whole output.
28- noTiles := !sc.mon.ov.open && view.usable.W*view.usable.H <= 028+ u := view.usable
29- if !sc.mon.ov.open && view.usable != view.frame {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- scene.TileClip = view.usable
31 }30 }
32 var real map[WindowID]Placement31 var real map[WindowID]Placement
33 if sc.mon.ov.open {32 if sc.mon.ov.open {
@@ -68,9 +67,6 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
68 // Only the focused output has an activated window.67 // Only the focused output has an activated window.
69 focused := p.Focused && i == c.focusScreen68 focused := p.Focused && i == c.focusScreen
70 sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade}69 sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade}
71- if noTiles && sw.Tile() {
72- sw.Hidden = true
73- }
74 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {70 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
75 // A scale motion: the content follows the drawn size. A71 // A scale motion: the content follows the drawn size. A
76 // card in flight drawn at its size still needs Zoom 1:72 // card in flight drawn at its size still needs Zoom 1:
@@ -82,7 +78,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
82 if p.Leaving {78 if p.Leaving {
83 // A window fading out after it closed or hid: drawn, but79 // A window fading out after it closed or hid: drawn, but
84 // hidden to everything else, its configures included.80 // hidden to everything else, its configures included.
85- sw.Hidden = noTiles && sw.Tile()81+ sw.Hidden = false
86 scene.Windows = append(scene.Windows, sw)82 scene.Windows = append(scene.Windows, sw)
87 // A window the workspace still holds (a hidden stash83 // A window the workspace still holds (a hidden stash
88 // window) keeps its last configure: prune would forget it,84 // window) keeps its last configure: prune would forget it,
@@ -93,7 +89,10 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
93 }89 }
94 continue90 continue
95 }91 }
96- if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {92+ // A window sliding in still waits for its pulse; one with no room
93+ // to be drawn (panels leave no usable area) never gets it.
94+ room := view.area(p)
95+ if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && room.W > 0 && room.H > 0 && p.Preview == 0 && !sc.mon.ov.open {
97 drawable = true96 drawable = true
98 if p.ID == c.pulse.id {97 if p.ID == c.pulse.id {
99 sw.FocusEffect = pulse98 sw.FocusEffect = pulse
M internal/core/viewport_test.go +32 -12
@@ -105,8 +105,8 @@ func TestConfigureVisibleCascadeUnderPanel(t *testing.T) {
105105 t.Fatal("window under the panel left the scene")
106106 }
107107 // It is cut at the panel and takes no pointer there.
108- if scene.TileClip != (Rect{W: 100, H: 60}) {
109- t.Fatalf("tile clip %+v, want the usable area", scene.TileClip)
108+ if scene.TileInset != (ports.Insets{Bottom: 20}) {
109+ t.Fatalf("tile inset %+v, want the panel's", scene.TileInset)
110110 }
111111 if id, _, _ := c.hit(10, 70); id != 0 {
112112 t.Fatalf("pointer over the panel went to window %d", id)
@@ -114,13 +114,24 @@ func TestConfigureVisibleCascadeUnderPanel(t *testing.T) {
114114 if id, _, _ := c.hit(10, 30); id != 1 {
115115 t.Fatalf("pointer over window 1 went to %d", id)
116116 }
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+ // The overview lays previews over the whole output: no inset.
126+ m.ToggleOverview()
127+ if s := check(nil); s.TileInset != (ports.Insets{}) {
128+ t.Fatalf("tile inset %+v in the overview", s.TileInset)
129+ }
130+ m.ToggleOverview()
117131 // Its band scrolled in, the first band is under nothing but off screen.
118132 w.FocusID(3)
119133 check(map[WindowID]bool{1: false, 2: false, 3: true})
120134 // Fullscreen covers the panel: always seen.
121- w.FocusID(1)
122- check(map[WindowID]bool{1: true, 3: false})
123- w.FocusID(3)
124135 w.ToggleFullscreen()
125136 check(map[WindowID]bool{3: true})
126137 }
@@ -167,16 +178,25 @@ func TestTileStopsAtPanel(t *testing.T) {
167178 }
168179 }
169180
170-// Panels that leave no room hide every tile: a zero TileClip would not
171-// clip them.
172-func TestNoUsableAreaHidesTiles(t *testing.T) {
181+// Panels that leave no room draw no tile, a leaving one included, and the
182+// focus pulse has nothing to show.
183+func TestNoUsableAreaDrawsNoTile(t *testing.T) {
173184 c, check := viewportCore(t, func(cfg *ports.Config) { cfg.Layout.MaxColumns = 1 }, Rect{})
174185 c.cur().mon.AddWindow(1)
186+ c.pulse.target = 1
175187 scene := check(map[WindowID]bool{1: false})
176- for _, sw := range scene.Windows {
177- if sw.ID == 1 && !sw.Hidden {
178- t.Fatalf("tile drawn with no usable area: %+v", sw)
179- }
188+ if scene.Draws(scene.Windows[0]) || c.pulse.drawable {
189+ t.Fatalf("tile drawn %v, pulse drawable %v with no usable area", scene.Draws(scene.Windows[0]), c.pulse.drawable)
190+ }
191+ sc := c.cur()
192+ sc.shown = slices.Clone(sc.shownLayout())
193+ sc.shown[0].Leaving = true
194+ scene, _, err := c.sceneFor(context.Background(), 0, sc, nil, nil, 0)
195+ if err != nil {
196+ t.Fatal(err)
197+ }
198+ if scene.Draws(scene.Windows[0]) {
199+ t.Fatalf("leaving tile drawn with no usable area: %+v", scene.Windows[0])
180200 }
181201 }
182202
@@ -105,8 +105,8 @@ func TestConfigureVisibleCascadeUnderPanel(t *testing.T) {
105 t.Fatal("window under the panel left the scene")105 t.Fatal("window under the panel left the scene")
106 }106 }
107 // It is cut at the panel and takes no pointer there.107 // It is cut at the panel and takes no pointer there.
108- if scene.TileClip != (Rect{W: 100, H: 60}) {108+ if scene.TileInset != (ports.Insets{Bottom: 20}) {
109- t.Fatalf("tile clip %+v, want the usable area", scene.TileClip)109+ t.Fatalf("tile inset %+v, want the panel's", scene.TileInset)
110 }110 }
111 if id, _, _ := c.hit(10, 70); id != 0 {111 if id, _, _ := c.hit(10, 70); id != 0 {
112 t.Fatalf("pointer over the panel went to window %d", id)112 t.Fatalf("pointer over the panel went to window %d", id)
@@ -114,13 +114,24 @@ func TestConfigureVisibleCascadeUnderPanel(t *testing.T) {
114 if id, _, _ := c.hit(10, 30); id != 1 {114 if id, _, _ := c.hit(10, 30); id != 1 {
115 t.Fatalf("pointer over window 1 went to %d", id)115 t.Fatalf("pointer over window 1 went to %d", id)
116 }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+ // The overview lays previews over the whole output: no inset.
126+ m.ToggleOverview()
127+ if s := check(nil); s.TileInset != (ports.Insets{}) {
128+ t.Fatalf("tile inset %+v in the overview", s.TileInset)
129+ }
130+ m.ToggleOverview()
117 // Its band scrolled in, the first band is under nothing but off screen.131 // Its band scrolled in, the first band is under nothing but off screen.
118 w.FocusID(3)132 w.FocusID(3)
119 check(map[WindowID]bool{1: false, 2: false, 3: true})133 check(map[WindowID]bool{1: false, 2: false, 3: true})
120 // Fullscreen covers the panel: always seen.134 // Fullscreen covers the panel: always seen.
121- w.FocusID(1)
122- check(map[WindowID]bool{1: true, 3: false})
123- w.FocusID(3)
124 w.ToggleFullscreen()135 w.ToggleFullscreen()
125 check(map[WindowID]bool{3: true})136 check(map[WindowID]bool{3: true})
126 }137 }
@@ -167,16 +178,25 @@ func TestTileStopsAtPanel(t *testing.T) {
167 }178 }
168 }179 }
169 180
170-// Panels that leave no room hide every tile: a zero TileClip would not181+// Panels that leave no room draw no tile, a leaving one included, and the
171-// clip them.182+// focus pulse has nothing to show.
172-func TestNoUsableAreaHidesTiles(t *testing.T) {183+func TestNoUsableAreaDrawsNoTile(t *testing.T) {
173 c, check := viewportCore(t, func(cfg *ports.Config) { cfg.Layout.MaxColumns = 1 }, Rect{})184 c, check := viewportCore(t, func(cfg *ports.Config) { cfg.Layout.MaxColumns = 1 }, Rect{})
174 c.cur().mon.AddWindow(1)185 c.cur().mon.AddWindow(1)
186+ c.pulse.target = 1
175 scene := check(map[WindowID]bool{1: false})187 scene := check(map[WindowID]bool{1: false})
176- for _, sw := range scene.Windows {188+ if scene.Draws(scene.Windows[0]) || c.pulse.drawable {
177- if sw.ID == 1 && !sw.Hidden {189+ t.Fatalf("tile drawn %v, pulse drawable %v with no usable area", scene.Draws(scene.Windows[0]), c.pulse.drawable)
178- t.Fatalf("tile drawn with no usable area: %+v", sw)190+ }
179- }191+ sc := c.cur()
192+ sc.shown = slices.Clone(sc.shownLayout())
193+ sc.shown[0].Leaving = true
194+ scene, _, err := c.sceneFor(context.Background(), 0, sc, nil, nil, 0)
195+ if err != nil {
196+ t.Fatal(err)
197+ }
198+ if scene.Draws(scene.Windows[0]) {
199+ t.Fatalf("leaving tile drawn with no usable area: %+v", scene.Windows[0])
180 }200 }
181 }201 }
182 202
M internal/ports/scene.go +37 -12
@@ -27,11 +27,12 @@ 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- // TileClip bounds tiles (SceneWindow.Tile) and their lines inside
31- // WorkspaceClip: the usable area exclusive layers leave, so a tile is
32- // never drawn over a panel, whatever its layer. Zero does not clip
33- // (overview, capture scenes).
34- TileClip Rect
30+ // TileInset is what exclusive layers (panels) take from the output's
31+ // edges. Tiles (SceneWindow.Tile) and their lines are drawn only in
32+ // what is left (TileArea), so a tile is never drawn over a panel,
33+ // whatever its layer; insets covering the output leave no tile drawn.
34+ // Zero: no panel (and the overview, capture scenes).
35+ TileInset Insets
3536 // Dim darkens the background, bottom layers and tiles (tile lines
3637 // included) inside WorkspaceClip when set, with black at this opacity,
3738 // 0 to 1, under the first
@@ -79,7 +80,7 @@ func (s Scene) SameAs(o Scene) bool {
7980 s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight ||
8081 s.Scale != o.Scale || s.Transform != o.Transform || s.Off != o.Off ||
8182 s.Background != o.Background || s.Border != o.Border ||
82- s.WorkspaceClip != o.WorkspaceClip || s.TileClip != o.TileClip || s.Dim != o.Dim || s.DimBehind != o.DimBehind {
83+ s.WorkspaceClip != o.WorkspaceClip || s.TileInset != o.TileInset || s.Dim != o.Dim || s.DimBehind != o.DimBehind {
8384 return false
8485 }
8586 if s.CaptureScene != nil || o.CaptureScene != nil {
@@ -102,13 +103,10 @@ func (s Scene) SameAs(o Scene) bool {
102103
103104 // Shows reports whether the scene draws the surface of id: only its
104105 // content changes need a new frame. A window scrolled off the output is
105-// not drawn. WorkspaceClip also excludes off-viewport windows, TileClip
106-// tiles under a panel; OverLayers popups remain output-wide.
106+// not drawn (Draws).
107107 func (s Scene) Shows(id WindowID) bool {
108108 for _, w := range s.Windows {
109- if w.ID == id && !w.Hidden && w.Rect.Overlaps(Rect{W: s.OutputWidth, H: s.OutputHeight}) &&
110- (w.OverLayers || s.WorkspaceClip == (Rect{}) || w.Rect.Overlaps(s.WorkspaceClip)) &&
111- (!w.Tile() || s.TileClip == (Rect{}) || w.Rect.Overlaps(s.TileClip)) {
109+ if w.ID == id && s.Draws(w) {
112110 return true
113111 }
114112 }
@@ -120,6 +118,33 @@ func (s Scene) Shows(id WindowID) bool {
120118 return false
121119 }
122120
121+// Draws reports whether any of w is drawn: not hidden, on the output,
122+// inside WorkspaceClip (OverLayers popups remain output-wide) and, for a
123+// tile, inside TileArea.
124+func (s Scene) Draws(w SceneWindow) bool {
125+ if w.Hidden || !w.Rect.Overlaps(Rect{W: s.OutputWidth, H: s.OutputHeight}) {
126+ return false
127+ }
128+ if !w.OverLayers && s.WorkspaceClip != (Rect{}) && !w.Rect.Overlaps(s.WorkspaceClip) {
129+ return false
130+ }
131+ if a, ok := s.TileArea(); ok && w.Tile() {
132+ return w.Rect.Overlaps(a)
133+ }
134+ return true
135+}
136+
137+// TileArea is where tiles are drawn: the output minus TileInset, empty
138+// when the panels cover it. ok is false when TileInset is zero: tiles are
139+// not clipped beyond the output and WorkspaceClip.
140+func (s Scene) TileArea() (r Rect, ok bool) {
141+ in := s.TileInset
142+ if in == (Insets{}) {
143+ return Rect{}, false
144+ }
145+ 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
146+}
147+
123148 // Border carries the window border style; colors are #rrggbb, "" skips
124149 // drawing. Width is in logical pixels.
125150 type Border struct {
@@ -188,7 +213,7 @@ type SceneWindow struct {
188213 }
189214
190215 // Tile reports whether w is a tile: laid out in the usable area and
191-// clipped to Scene.TileClip. Floats, fullscreen windows, overview previews
216+// clipped to Scene.TileArea. Floats, fullscreen windows, overview previews
192217 // and popups are placed against the whole viewport.
193218 func (w SceneWindow) Tile() bool {
194219 return !w.Floating && !w.Fullscreen && !w.Popup && w.Preview == 0
@@ -27,11 +27,12 @@ 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- // TileClip bounds tiles (SceneWindow.Tile) and their lines inside30+ // TileInset is what exclusive layers (panels) take from the output's
31- // WorkspaceClip: the usable area exclusive layers leave, so a tile is31+ // edges. Tiles (SceneWindow.Tile) and their lines are drawn only in
32- // never drawn over a panel, whatever its layer. Zero does not clip32+ // what is left (TileArea), so a tile is never drawn over a panel,
33- // (overview, capture scenes).33+ // whatever its layer; insets covering the output leave no tile drawn.
34- TileClip Rect34+ // Zero: no panel (and the overview, capture scenes).
35+ TileInset Insets
35 // Dim darkens the background, bottom layers and tiles (tile lines36 // Dim darkens the background, bottom layers and tiles (tile lines
36 // included) inside WorkspaceClip when set, with black at this opacity,37 // included) inside WorkspaceClip when set, with black at this opacity,
37 // 0 to 1, under the first38 // 0 to 1, under the first
@@ -79,7 +80,7 @@ func (s Scene) SameAs(o Scene) bool {
79 s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight ||80 s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight ||
80 s.Scale != o.Scale || s.Transform != o.Transform || s.Off != o.Off ||81 s.Scale != o.Scale || s.Transform != o.Transform || s.Off != o.Off ||
81 s.Background != o.Background || s.Border != o.Border ||82 s.Background != o.Background || s.Border != o.Border ||
82- s.WorkspaceClip != o.WorkspaceClip || s.TileClip != o.TileClip || s.Dim != o.Dim || s.DimBehind != o.DimBehind {83+ s.WorkspaceClip != o.WorkspaceClip || s.TileInset != o.TileInset || s.Dim != o.Dim || s.DimBehind != o.DimBehind {
83 return false84 return false
84 }85 }
85 if s.CaptureScene != nil || o.CaptureScene != nil {86 if s.CaptureScene != nil || o.CaptureScene != nil {
@@ -102,13 +103,10 @@ func (s Scene) SameAs(o Scene) bool {
102 103
103 // Shows reports whether the scene draws the surface of id: only its104 // Shows reports whether the scene draws the surface of id: only its
104 // content changes need a new frame. A window scrolled off the output is105 // content changes need a new frame. A window scrolled off the output is
105-// not drawn. WorkspaceClip also excludes off-viewport windows, TileClip106+// not drawn (Draws).
106-// tiles under a panel; OverLayers popups remain output-wide.
107 func (s Scene) Shows(id WindowID) bool {107 func (s Scene) Shows(id WindowID) bool {
108 for _, w := range s.Windows {108 for _, w := range s.Windows {
109- if w.ID == id && !w.Hidden && w.Rect.Overlaps(Rect{W: s.OutputWidth, H: s.OutputHeight}) &&109+ if w.ID == id && s.Draws(w) {
110- (w.OverLayers || s.WorkspaceClip == (Rect{}) || w.Rect.Overlaps(s.WorkspaceClip)) &&
111- (!w.Tile() || s.TileClip == (Rect{}) || w.Rect.Overlaps(s.TileClip)) {
112 return true110 return true
113 }111 }
114 }112 }
@@ -120,6 +118,33 @@ func (s Scene) Shows(id WindowID) bool {
120 return false118 return false
121 }119 }
122 120
121+// Draws reports whether any of w is drawn: not hidden, on the output,
122+// inside WorkspaceClip (OverLayers popups remain output-wide) and, for a
123+// tile, inside TileArea.
124+func (s Scene) Draws(w SceneWindow) bool {
125+ if w.Hidden || !w.Rect.Overlaps(Rect{W: s.OutputWidth, H: s.OutputHeight}) {
126+ return false
127+ }
128+ if !w.OverLayers && s.WorkspaceClip != (Rect{}) && !w.Rect.Overlaps(s.WorkspaceClip) {
129+ return false
130+ }
131+ if a, ok := s.TileArea(); ok && w.Tile() {
132+ return w.Rect.Overlaps(a)
133+ }
134+ return true
135+}
136+
137+// TileArea is where tiles are drawn: the output minus TileInset, empty
138+// when the panels cover it. ok is false when TileInset is zero: tiles are
139+// not clipped beyond the output and WorkspaceClip.
140+func (s Scene) TileArea() (r Rect, ok bool) {
141+ in := s.TileInset
142+ if in == (Insets{}) {
143+ return Rect{}, false
144+ }
145+ 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
146+}
147+
123 // Border carries the window border style; colors are #rrggbb, "" skips148 // Border carries the window border style; colors are #rrggbb, "" skips
124 // drawing. Width is in logical pixels.149 // drawing. Width is in logical pixels.
125 type Border struct {150 type Border struct {
@@ -188,7 +213,7 @@ type SceneWindow struct {
188 }213 }
189 214
190 // Tile reports whether w is a tile: laid out in the usable area and215 // Tile reports whether w is a tile: laid out in the usable area and
191-// clipped to Scene.TileClip. Floats, fullscreen windows, overview previews216+// clipped to Scene.TileArea. Floats, fullscreen windows, overview previews
192 // and popups are placed against the whole viewport.217 // and popups are placed against the whole viewport.
193 func (w SceneWindow) Tile() bool {218 func (w SceneWindow) Tile() bool {
194 return !w.Floating && !w.Fullscreen && !w.Popup && w.Preview == 0219 return !w.Floating && !w.Fullscreen && !w.Popup && w.Preview == 0
M internal/ports/scene_same_test.go +1 -1
@@ -48,7 +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 clip": func(s *Scene) { s.TileClip.H = 1 },
51+ "tile inset": func(s *Scene) { s.TileInset.Bottom = 1 },
5252 "dim": func(s *Scene) { s.Dim = 0.5 },
5353 "dim behind": func(s *Scene) { s.DimBehind = true },
5454 "separator": func(s *Scene) { s.Separators[0].Active = true },
@@ -48,7 +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 clip": func(s *Scene) { s.TileClip.H = 1 },51+ "tile inset": func(s *Scene) { s.TileInset.Bottom = 1 },
52 "dim": func(s *Scene) { s.Dim = 0.5 },52 "dim": func(s *Scene) { s.Dim = 0.5 },
53 "dim behind": func(s *Scene) { s.DimBehind = true },53 "dim behind": func(s *Scene) { s.DimBehind = true },
54 "separator": func(s *Scene) { s.Separators[0].Active = true },54 "separator": func(s *Scene) { s.Separators[0].Active = true },
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 +20 -6
@@ -17,11 +17,12 @@ func TestSceneShowsWorkspaceClip(t *testing.T) {
1717 }
1818 }
1919
20-// A tile under a panel (outside TileClip) is not drawn; a float, a
21-// fullscreen window, a preview or a popup there still is.
22-func TestSceneShowsTileClip(t *testing.T) {
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) {
2324 under := Rect{Y: 80, W: 50, H: 80}
24- s := Scene{OutputWidth: 100, OutputHeight: 100, TileClip: Rect{W: 100, H: 80}, Windows: []SceneWindow{
25+ s := Scene{OutputWidth: 100, OutputHeight: 100, TileInset: Insets{Bottom: 20}, Windows: []SceneWindow{
2526 {ID: 1, Rect: Rect{W: 50, H: 80}},
2627 {ID: 2, Rect: under},
2728 {ID: 3, Rect: under, Floating: true},
@@ -34,8 +35,21 @@ func TestSceneShowsTileClip(t *testing.T) {
3435 t.Errorf("window %d shown %v, want %v", id, got, want)
3536 }
3637 }
37- s.TileClip = Rect{}
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{}
3843 if !s.Shows(2) {
39- t.Fatal("tile under no clip not shown")
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+ }
4054 }
4155 }
@@ -17,11 +17,12 @@ func TestSceneShowsWorkspaceClip(t *testing.T) {
17 }17 }
18 }18 }
19 19
20-// A tile under a panel (outside TileClip) is not drawn; a float, a20+// A tile under a panel (outside TileArea) is not drawn; a float, a
21-// fullscreen window, a preview or a popup there still is.21+// fullscreen window, a preview or a popup there still is. Panels covering
22-func TestSceneShowsTileClip(t *testing.T) {22+// the whole output leave no tile drawn.
23+func TestSceneShowsTileInset(t *testing.T) {
23 under := Rect{Y: 80, W: 50, H: 80}24 under := Rect{Y: 80, W: 50, H: 80}
24- s := Scene{OutputWidth: 100, OutputHeight: 100, TileClip: Rect{W: 100, H: 80}, Windows: []SceneWindow{25+ s := Scene{OutputWidth: 100, OutputHeight: 100, TileInset: Insets{Bottom: 20}, Windows: []SceneWindow{
25 {ID: 1, Rect: Rect{W: 50, H: 80}},26 {ID: 1, Rect: Rect{W: 50, H: 80}},
26 {ID: 2, Rect: under},27 {ID: 2, Rect: under},
27 {ID: 3, Rect: under, Floating: true},28 {ID: 3, Rect: under, Floating: true},
@@ -34,8 +35,21 @@ func TestSceneShowsTileClip(t *testing.T) {
34 t.Errorf("window %d shown %v, want %v", id, got, want)35 t.Errorf("window %d shown %v, want %v", id, got, want)
35 }36 }
36 }37 }
37- s.TileClip = Rect{}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{}
38 if !s.Shows(2) {43 if !s.Shows(2) {
39- t.Fatal("tile under no clip not shown")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+ }
40 }54 }
41 }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