feat(render): clip tiles to the usable area
Scene.TileClip carries the usable area exclusive layers leave. The renderer cuts tiles and their lines at it, the overlay plane refuses a tile it cuts, and core sends the pointer only to the drawn part. A tile is never drawn over a panel, bottom layer ones included, so a window the user does not see is the one viewport.shows marks invisible.
- commit
- 1466f785a25f14a20234cdc49ecda4b3c99bcf88
- author
- brice · · 2026-10-08 13:56 UTC
- parent
- b264e57
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +152 -26
M internal/adapters/drm/overlay.go +5 -0
| @@ -153,6 +153,11 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 153 | 153 | // The plane cannot apply the workspace's logical viewport crop. | |
| 154 | 154 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "workspace_clip" | |
| 155 | 155 | } | |
| 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) { | |
| 158 | + | // Nor the cut of a tile at a panel. | |
| 159 | + | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "tile_clip" | |
| 160 | + | } | |
| 156 | 161 | c := surfaces[pick.ID] | |
| 157 | 162 | pw, ph := float64(pick.Rect.W)*scale, float64(pick.Rect.H)*scale | |
| 158 | 163 | if float64(c.Width) != pw || float64(c.Height) != ph || c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0) { | |
| @@ -153,6 +153,11 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 153 | // The plane cannot apply the workspace's logical viewport crop. | 153 | // The plane cannot apply the workspace's logical viewport crop. | ||
| 154 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "workspace_clip" | 154 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "workspace_clip" | ||
| 155 | } | 155 | } | ||
| 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) { | |||
| 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)*scale | 162 | 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 +14 -0
| @@ -63,6 +63,20 @@ 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 it | |
| 67 | + | // whole, over the panel. | |
| 68 | + | func TestOverlayTileClip(t *testing.T) { | |
| 69 | + | s, contents := overlayScene() | |
| 70 | + | s.TileClip = ports.Rect{W: 200, H: 80} | |
| 71 | + | if _, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "tile_clip" { | |
| 72 | + | t.Fatalf("tile under a panel on the overlay: %q", reason) | |
| 73 | + | } | |
| 74 | + | s.TileClip = ports.Rect{W: 200, H: 100} | |
| 75 | + | 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) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 66 | 80 | func TestOverlayCandidate(t *testing.T) { | |
| 67 | 81 | s, c := overlayScene() | |
| 68 | 82 | if w, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" || w.ID != 2 { | |
| @@ -63,6 +63,20 @@ 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 it | |||
| 67 | + | // whole, over the panel. | |||
| 68 | + | func TestOverlayTileClip(t *testing.T) { | |||
| 69 | + | s, contents := overlayScene() | |||
| 70 | + | s.TileClip = ports.Rect{W: 200, H: 80} | |||
| 71 | + | if _, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "tile_clip" { | |||
| 72 | + | t.Fatalf("tile under a panel on the overlay: %q", reason) | |||
| 73 | + | } | |||
| 74 | + | s.TileClip = ports.Rect{W: 200, H: 100} | |||
| 75 | + | 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) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 66 | func TestOverlayCandidate(t *testing.T) { | 80 | func TestOverlayCandidate(t *testing.T) { | ||
| 67 | s, c := overlayScene() | 81 | s, c := overlayScene() | ||
| 68 | if w, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" || w.ID != 2 { | 82 | if w, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" || w.ID != 2 { | ||
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 | |||
| 230 | 230 | old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight || | |
| 231 | 231 | old.Scale != cur.Scale || old.Transform != cur.Transform || old.Off != cur.Off || | |
| 232 | 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.TileClip != cur.TileClip || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind || | |
| 234 | 234 | old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil || | |
| 235 | 235 | !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) || | |
| 236 | 236 | !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.TileClip != cur.TileClip || 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 ( | |||
| 17 | 17 | // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta | |
| 18 | 18 | // lists the fields that force a full redraw. | |
| 19 | 19 | func TestSceneFieldCount(t *testing.T) { | |
| 20 | - | const fields = 20 | |
| 20 | + | const fields = 21 | |
| 21 | 21 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))), | |
| 22 | 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 | 98 | "background": func(s *ports.Scene) { s.Background = "#111111" }, | |
| 99 | 99 | "border": func(s *ports.Scene) { s.Border.Width = 2 }, | |
| 100 | 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 | 102 | "dim": func(s *ports.Scene) { s.Dim = 0.5 }, | |
| 102 | 103 | "dim behind": func(s *ports.Scene) { s.DimBehind = true }, | |
| 103 | 104 | "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: sceneDelta | 17 | // 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 = 20 | 20 | + | 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 clip": func(s *ports.Scene) { s.TileClip = ports.Rect{W: 10, H: 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 +14 -2
| @@ -28,7 +28,9 @@ type sceneWalk struct { | |||
| 28 | 28 | // it (premultiplied); 1 otherwise. | |
| 29 | 29 | alpha float32 | |
| 30 | 30 | bounds image.Rectangle | |
| 31 | - | draws []draw | |
| 31 | + | // tileBounds is bounds cut to Scene.TileClip: where tiles draw. | |
| 32 | + | tileBounds image.Rectangle | |
| 33 | + | draws []draw | |
| 32 | 34 | } | |
| 33 | 35 | ||
| 34 | 36 | // draws walks the scene into quads in paint order. | |
| @@ -48,6 +50,11 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||
| 48 | 50 | c := s.WorkspaceClip | |
| 49 | 51 | w.bounds = w.bounds.Intersect(w.physRect(c.X, c.Y, c.W, c.H)) | |
| 50 | 52 | } | |
| 53 | + | 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)) | |
| 57 | + | } | |
| 51 | 58 | w.windows() | |
| 52 | 59 | w.popups(false) | |
| 53 | 60 | w.dropHints() | |
| @@ -219,6 +226,11 @@ func (w *sceneWalk) windows() { | |||
| 219 | 226 | continue | |
| 220 | 227 | } | |
| 221 | 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.TileClip). | |
| 232 | + | w.bounds = w.tileBounds | |
| 233 | + | } | |
| 222 | 234 | // Content sits inside the border; core sized the client to match. | |
| 223 | 235 | b, inset := 0, ports.Sides(0) | |
| 224 | 236 | if !win.Fullscreen { | |
| @@ -249,7 +261,7 @@ func (w *sceneWalk) windows() { | |||
| 249 | 261 | // A stashed window peeking in: dimmed with its border. | |
| 250 | 262 | w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim) | |
| 251 | 263 | } | |
| 252 | - | w.alpha = 1 | |
| 264 | + | w.alpha, w.bounds = 1, windowBounds | |
| 253 | 265 | } | |
| 254 | 266 | if !tileLines { | |
| 255 | 267 | w.separators(0) | |
| @@ -28,7 +28,9 @@ type sceneWalk struct { | |||||
| 28 | // it (premultiplied); 1 otherwise. | 28 | // it (premultiplied); 1 otherwise. | ||
| 29 | alpha float32 | 29 | alpha float32 | ||
| 30 | bounds image.Rectangle | 30 | bounds image.Rectangle | ||
| 31 | - | draws []draw | 31 | + | // tileBounds is bounds cut to Scene.TileClip: where tiles draw. |
| 32 | + | tileBounds image.Rectangle | |||
| 33 | + | draws []draw | |||
| 32 | } | 34 | } | ||
| 33 | 35 | ||||
| 34 | // draws walks the scene into quads in paint order. | 36 | // draws walks the scene into quads in paint order. | ||
| @@ -48,6 +50,11 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||||
| 48 | c := s.WorkspaceClip | 50 | c := s.WorkspaceClip | ||
| 49 | w.bounds = w.bounds.Intersect(w.physRect(c.X, c.Y, c.W, c.H)) | 51 | w.bounds = w.bounds.Intersect(w.physRect(c.X, c.Y, c.W, c.H)) | ||
| 50 | } | 52 | } | ||
| 53 | + | 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)) | |||
| 57 | + | } | |||
| 51 | w.windows() | 58 | w.windows() | ||
| 52 | w.popups(false) | 59 | w.popups(false) | ||
| 53 | w.dropHints() | 60 | w.dropHints() | ||
| @@ -219,6 +226,11 @@ func (w *sceneWalk) windows() { | |||||
| 219 | continue | 226 | 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.TileClip). | |||
| 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 { | ||
| @@ -249,7 +261,7 @@ func (w *sceneWalk) windows() { | |||||
| 249 | // A stashed window peeking in: dimmed with its border. | 261 | // 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) | 262 | w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim) | ||
| 251 | } | 263 | } | ||
| 252 | - | w.alpha = 1 | 264 | + | w.alpha, w.bounds = 1, windowBounds |
| 253 | } | 265 | } | ||
| 254 | if !tileLines { | 266 | if !tileLines { | ||
| 255 | w.separators(0) | 267 | w.separators(0) | ||
M internal/adapters/vulkan/workspace_clip_test.go +35 -0
| @@ -8,6 +8,41 @@ import ( | |||
| 8 | 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 stays | |
| 12 | + | // visible; a float there is drawn over it. | |
| 13 | + | func TestTileClipKeepsBottomPanel(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", 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}}, | |
| 26 | + | } | |
| 27 | + | if err := render(r, scene, contents); err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | 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} { | |
| 32 | + | if got := pixels.RGBAAt(point.X, point.Y); got != want { | |
| 33 | + | t.Errorf("pixel %v=%v, want %v", point, got, want) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | scene.Seq++ | |
| 37 | + | 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}} | |
| 38 | + | if err := render(r, scene, contents); err != nil { | |
| 39 | + | t.Fatal(err) | |
| 40 | + | } | |
| 41 | + | if got := readPixels(t, r).RGBAAt(2, 44); got != blue { | |
| 42 | + | t.Fatalf("float clipped at the panel: %v", got) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 11 | 46 | func TestWorkspaceClipWindowsPopupsAndLayers(t *testing.T) { | |
| 12 | 47 | r, err := New(64, 48) | |
| 13 | 48 | if err != nil { | |
| @@ -8,6 +8,41 @@ 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 stays | |||
| 12 | + | // visible; a float there is drawn over it. | |||
| 13 | + | func TestTileClipKeepsBottomPanel(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", 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}}, | |||
| 26 | + | } | |||
| 27 | + | if err := render(r, scene, contents); err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | 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} { | |||
| 32 | + | if got := pixels.RGBAAt(point.X, point.Y); got != want { | |||
| 33 | + | t.Errorf("pixel %v=%v, want %v", point, got, want) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | scene.Seq++ | |||
| 37 | + | 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}} | |||
| 38 | + | if err := render(r, scene, contents); err != nil { | |||
| 39 | + | t.Fatal(err) | |||
| 40 | + | } | |||
| 41 | + | if got := readPixels(t, r).RGBAAt(2, 44); got != blue { | |||
| 42 | + | t.Fatalf("float clipped at the panel: %v", got) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 11 | func TestWorkspaceClipWindowsPopupsAndLayers(t *testing.T) { | 46 | func TestWorkspaceClipWindowsPopupsAndLayers(t *testing.T) { | ||
| 12 | r, err := New(64, 48) | 47 | r, err := New(64, 48) | ||
| 13 | if err != nil { | 48 | if err != nil { | ||
M internal/core/core.go +17 -12
| @@ -465,18 +465,21 @@ type viewport struct{ frame, usable Rect } | |||
| 465 | 465 | // whole is a viewport with no panel: all of r is usable. | |
| 466 | 466 | func whole(r Rect) viewport { return viewport{frame: r, usable: r} } | |
| 467 | 467 | ||
| 468 | - | // shows reports whether the user sees p: not hidden, and overlapping the | |
| 469 | - | // area its kind is laid out in. A tile lives in the usable area, so a | |
| 470 | - | // cascade band or a column scrolled under a panel is not seen; a float, a | |
| 471 | - | // fullscreen window and an overview preview are placed against the frame. | |
| 472 | - | func (v viewport) shows(p Placement) bool { | |
| 473 | - | if p.Hidden { | |
| 474 | - | return false | |
| 475 | - | } | |
| 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.TileClip), 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 { | |
| 476 | 473 | if p.Floating || p.Fullscreen || p.Preview > 0 { | |
| 477 | - | return p.Rect.Overlaps(v.frame) | |
| 474 | + | return v.frame | |
| 478 | 475 | } | |
| 479 | - | return p.Rect.Overlaps(v.usable) | |
| 476 | + | return v.usable | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | // shows reports whether the user sees p: not hidden, and overlapping its | |
| 480 | + | // area. | |
| 481 | + | func (v viewport) shows(p Placement) bool { | |
| 482 | + | return !p.Hidden && p.Rect.Overlaps(v.area(p)) | |
| 480 | 483 | } | |
| 481 | 484 | ||
| 482 | 485 | // floatDim is the veil opacity of a layout: dim only when a float is | |
| @@ -824,8 +827,7 @@ func (c *Core) shownClient(id WindowID) (full, visible Rect, ok bool) { | |||
| 824 | 827 | } | |
| 825 | 828 | r := c.clientRect(p) | |
| 826 | 829 | full = Rect{X: r.X + s.x, Y: r.Y + s.y, W: r.W, H: r.H} | |
| 827 | - | f := s.mon.Frame() | |
| 828 | - | visible = intersect(r, f) | |
| 830 | + | visible = intersect(r, s.mon.viewport().area(p)) | |
| 829 | 831 | visible.X, visible.Y = visible.X+s.x, visible.Y+s.y | |
| 830 | 832 | if visible.W <= 0 || visible.H <= 0 { | |
| 831 | 833 | return Rect{}, Rect{}, false | |
| @@ -971,6 +973,7 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 971 | 973 | } | |
| 972 | 974 | var id WindowID | |
| 973 | 975 | var sx, sy float64 | |
| 976 | + | view := sc.mon.viewport() | |
| 974 | 977 | for _, p := range sc.shownLayout() { | |
| 975 | 978 | r := c.clientRect(p) | |
| 976 | 979 | // A peek is clickable wherever it shows, border included: it may | |
| @@ -979,6 +982,8 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 979 | 982 | if p.Peek { | |
| 980 | 983 | box = p.Rect | |
| 981 | 984 | } | |
| 985 | + | // Only what is drawn takes the pointer: a tile not over a panel. | |
| 986 | + | box = intersect(box, view.area(p)) | |
| 982 | 987 | 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) { | |
| 983 | 988 | if p.Peek { | |
| 984 | 989 | 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)) | |
| @@ -465,18 +465,21 @@ type viewport struct{ frame, usable Rect } | |||||
| 465 | // whole is a viewport with no panel: all of r is usable. | 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} } | 466 | func whole(r Rect) viewport { return viewport{frame: r, usable: r} } | ||
| 467 | 467 | ||||
| 468 | - | // shows reports whether the user sees p: not hidden, and overlapping the | 468 | + | // area is where p can be seen, drawn and pointed at. A tile lives in the |
| 469 | - | // area its kind is laid out in. A tile lives in the usable area, so a | 469 | + | // usable area and is cut at its edge (Scene.TileClip), so a cascade band |
| 470 | - | // cascade band or a column scrolled under a panel is not seen; a float, a | 470 | + | // or a column scrolled under a panel is not seen; a float, a fullscreen |
| 471 | - | // fullscreen 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) shows(p Placement) bool { | 472 | + | func (v viewport) area(p Placement) Rect { |
| 473 | - | if p.Hidden { | |||
| 474 | - | return false | |||
| 475 | - | } | |||
| 476 | if p.Floating || p.Fullscreen || p.Preview > 0 { | 473 | if p.Floating || p.Fullscreen || p.Preview > 0 { | ||
| 477 | - | return p.Rect.Overlaps(v.frame) | 474 | + | return v.frame |
| 478 | } | 475 | } | ||
| 479 | - | return p.Rect.Overlaps(v.usable) | 476 | + | return v.usable |
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | // shows reports whether the user sees p: not hidden, and overlapping its | |||
| 480 | + | // area. | |||
| 481 | + | func (v viewport) shows(p Placement) bool { | |||
| 482 | + | return !p.Hidden && p.Rect.Overlaps(v.area(p)) | |||
| 480 | } | 483 | } | ||
| 481 | 484 | ||||
| 482 | // floatDim is the veil opacity of a layout: dim only when a float is | 485 | // floatDim is the veil opacity of a layout: dim only when a float is | ||
| @@ -824,8 +827,7 @@ func (c *Core) shownClient(id WindowID) (full, visible Rect, ok bool) { | |||||
| 824 | } | 827 | } | ||
| 825 | r := c.clientRect(p) | 828 | r := c.clientRect(p) | ||
| 826 | full = Rect{X: r.X + s.x, Y: r.Y + s.y, W: r.W, H: r.H} | 829 | full = Rect{X: r.X + s.x, Y: r.Y + s.y, W: r.W, H: r.H} | ||
| 827 | - | f := s.mon.Frame() | 830 | + | visible = intersect(r, s.mon.viewport().area(p)) |
| 828 | - | visible = intersect(r, f) | |||
| 829 | visible.X, visible.Y = visible.X+s.x, visible.Y+s.y | 831 | visible.X, visible.Y = visible.X+s.x, visible.Y+s.y | ||
| 830 | if visible.W <= 0 || visible.H <= 0 { | 832 | if visible.W <= 0 || visible.H <= 0 { | ||
| 831 | return Rect{}, Rect{}, false | 833 | return Rect{}, Rect{}, false | ||
| @@ -971,6 +973,7 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||||
| 971 | } | 973 | } | ||
| 972 | var id WindowID | 974 | var id WindowID | ||
| 973 | var sx, sy float64 | 975 | var sx, sy float64 | ||
| 976 | + | view := sc.mon.viewport() | |||
| 974 | for _, p := range sc.shownLayout() { | 977 | for _, p := range sc.shownLayout() { | ||
| 975 | r := c.clientRect(p) | 978 | r := c.clientRect(p) | ||
| 976 | // A peek is clickable wherever it shows, border included: it may | 979 | // A peek is clickable wherever it shows, border included: it may | ||
| @@ -979,6 +982,8 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||||
| 979 | if p.Peek { | 982 | if p.Peek { | ||
| 980 | box = p.Rect | 983 | box = p.Rect | ||
| 981 | } | 984 | } | ||
| 985 | + | // Only what is drawn takes the pointer: a tile not over a panel. | |||
| 986 | + | box = intersect(box, view.area(p)) | |||
| 982 | 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) { | 987 | 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) { | ||
| 983 | if p.Peek { | 988 | if p.Peek { | ||
| 984 | 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)) | 989 | 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/scene.go +5 -0
| @@ -22,6 +22,11 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||
| 22 | 22 | // windows are going, and alone sizes the configures. | |
| 23 | 23 | layout, settled := sc.shown, sc.settledLayout | |
| 24 | 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 && view.usable != view.frame { | |
| 26 | + | // Tiles stop at the panels; the overview lays out previews over | |
| 27 | + | // the whole output. | |
| 28 | + | scene.TileClip = view.usable | |
| 29 | + | } | |
| 25 | 30 | var real map[WindowID]Placement | |
| 26 | 31 | if sc.mon.ov.open { | |
| 27 | 32 | // Local to this screen's build: reused, cleared each time. | |
| @@ -22,6 +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.settledLayout | 23 | 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 && view.usable != view.frame { | |||
| 26 | + | // Tiles stop at the panels; the overview lays out previews over | |||
| 27 | + | // the whole output. | |||
| 28 | + | scene.TileClip = view.usable | |||
| 29 | + | } | |||
| 25 | var real map[WindowID]Placement | 30 | var real map[WindowID]Placement | ||
| 26 | if sc.mon.ov.open { | 31 | if sc.mon.ov.open { | ||
| 27 | // Local to this screen's build: reused, cleared each time. | 32 | // Local to this screen's build: reused, cleared each time. | ||
M internal/core/separators.go +7 -5
| @@ -18,9 +18,9 @@ import "github.com/bnema/neferwl/internal/ports" | |||
| 18 | 18 | ||
| 19 | 19 | var sides = [...]ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom} | |
| 20 | 20 | ||
| 21 | - | // separators are the lines of the windows seen in v, clipped to its frame, | |
| 22 | - | // inactive ones first so active ones draw over them. lit says whether the | |
| 23 | - | // focused tile shows focus (only on the focused output). | |
| 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 | 24 | func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Separator { | |
| 25 | 25 | if width <= 0 { | |
| 26 | 26 | return nil | |
| @@ -28,8 +28,10 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se | |||
| 28 | 28 | o := v.frame | |
| 29 | 29 | var out []ports.Separator | |
| 30 | 30 | add := func(p *Placement, r Rect, active bool) { | |
| 31 | - | // A line cannot belong to a scrolled-off window or bleed onto | |
| 32 | - | // another output, even when a neighboring tile remains visible. | |
| 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 | + | o := v.area(*p) | |
| 33 | 35 | x0, y0 := max(r.X, o.X), max(r.Y, o.Y) | |
| 34 | 36 | x1, y1 := min(r.X+r.W, o.X+o.W), min(r.Y+r.H, o.Y+o.H) | |
| 35 | 37 | if x0 >= x1 || y0 >= y1 { | |
| @@ -18,9 +18,9 @@ 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 the windows seen in v, clipped to its frame, | 21 | + | // separators are the lines of the windows seen in v, each clipped to its |
| 22 | - | // inactive ones first so active ones draw over them. lit says whether the | 22 | + | // window's area, inactive ones first so active ones draw over them. lit |
| 23 | - | // focused tile shows 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, v viewport, 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 nil | 26 | return nil | ||
| @@ -28,8 +28,10 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se | |||||
| 28 | o := v.frame | 28 | o := v.frame | ||
| 29 | var out []ports.Separator | 29 | var out []ports.Separator | ||
| 30 | add := func(p *Placement, r Rect, active bool) { | 30 | add := func(p *Placement, r Rect, active bool) { | ||
| 31 | - | // A line cannot belong to a scrolled-off window or bleed onto | 31 | + | // A line cannot belong to a scrolled-off window, bleed onto |
| 32 | - | // another output, even when a neighboring tile remains visible. | 32 | + | // another output or, for a tile, over a panel, even when a |
| 33 | + | // neighboring tile remains visible. | |||
| 34 | + | o := v.area(*p) | |||
| 33 | x0, y0 := max(r.X, o.X), max(r.Y, o.Y) | 35 | x0, y0 := max(r.X, o.X), max(r.Y, o.Y) | ||
| 34 | x1, y1 := min(r.X+r.W, o.X+o.W), min(r.Y+r.H, o.Y+o.H) | 36 | x1, y1 := min(r.X+r.W, o.X+o.W), min(r.Y+r.H, o.Y+o.H) | ||
| 35 | if x0 >= x1 || y0 >= y1 { | 37 | if x0 >= x1 || y0 >= y1 { | ||
M internal/core/viewport_test.go +10 -0
| @@ -103,6 +103,16 @@ func TestConfigureVisibleCascadeUnderPanel(t *testing.T) { | |||
| 103 | 103 | if !found { | |
| 104 | 104 | t.Fatal("window under the panel left the scene") | |
| 105 | 105 | } | |
| 106 | + | // It is cut at the panel and takes no pointer there. | |
| 107 | + | if scene.TileClip != (Rect{W: 100, H: 60}) { | |
| 108 | + | t.Fatalf("tile clip %+v, want the usable area", scene.TileClip) | |
| 109 | + | } | |
| 110 | + | if id, _, _ := c.hit(10, 70); id != 0 { | |
| 111 | + | t.Fatalf("pointer over the panel went to window %d", id) | |
| 112 | + | } | |
| 113 | + | if id, _, _ := c.hit(10, 30); id != 1 { | |
| 114 | + | t.Fatalf("pointer over window 1 went to %d", id) | |
| 115 | + | } | |
| 106 | 116 | // Its band scrolled in, the first band is under nothing but off screen. | |
| 107 | 117 | w.FocusID(3) | |
| 108 | 118 | check(map[WindowID]bool{1: false, 2: false, 3: true}) | |
| @@ -103,6 +103,16 @@ func TestConfigureVisibleCascadeUnderPanel(t *testing.T) { | |||||
| 103 | if !found { | 103 | if !found { | ||
| 104 | t.Fatal("window under the panel left the scene") | 104 | t.Fatal("window under the panel left the scene") | ||
| 105 | } | 105 | } | ||
| 106 | + | // It is cut at the panel and takes no pointer there. | |||
| 107 | + | if scene.TileClip != (Rect{W: 100, H: 60}) { | |||
| 108 | + | t.Fatalf("tile clip %+v, want the usable area", scene.TileClip) | |||
| 109 | + | } | |||
| 110 | + | if id, _, _ := c.hit(10, 70); id != 0 { | |||
| 111 | + | t.Fatalf("pointer over the panel went to window %d", id) | |||
| 112 | + | } | |||
| 113 | + | if id, _, _ := c.hit(10, 30); id != 1 { | |||
| 114 | + | t.Fatalf("pointer over window 1 went to %d", id) | |||
| 115 | + | } | |||
| 106 | // Its band scrolled in, the first band is under nothing but off screen. | 116 | // Its band scrolled in, the first band is under nothing but off screen. | ||
| 107 | w.FocusID(3) | 117 | w.FocusID(3) | ||
| 108 | check(map[WindowID]bool{1: false, 2: false, 3: true}) | 118 | check(map[WindowID]bool{1: false, 2: false, 3: true}) | ||
M internal/ports/scene.go +17 -4
| @@ -27,6 +27,11 @@ type Scene struct { | |||
| 27 | 27 | // in logical output coordinates. Zero inherits the full output. Layers | |
| 28 | 28 | // remain output-wide; overview previews do not use this clip. | |
| 29 | 29 | 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 | 35 | // Dim darkens the background, bottom layers and tiles (tile lines | |
| 31 | 36 | // included) inside WorkspaceClip when set, with black at this opacity, | |
| 32 | 37 | // 0 to 1, under the first | |
| @@ -74,7 +79,7 @@ func (s Scene) SameAs(o Scene) bool { | |||
| 74 | 79 | s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight || | |
| 75 | 80 | s.Scale != o.Scale || s.Transform != o.Transform || s.Off != o.Off || | |
| 76 | 81 | s.Background != o.Background || s.Border != o.Border || | |
| 77 | - | s.WorkspaceClip != o.WorkspaceClip || s.Dim != o.Dim || s.DimBehind != o.DimBehind { | |
| 82 | + | s.WorkspaceClip != o.WorkspaceClip || s.TileClip != o.TileClip || s.Dim != o.Dim || s.DimBehind != o.DimBehind { | |
| 78 | 83 | return false | |
| 79 | 84 | } | |
| 80 | 85 | if s.CaptureScene != nil || o.CaptureScene != nil { | |
| @@ -97,12 +102,13 @@ func (s Scene) SameAs(o Scene) bool { | |||
| 97 | 102 | ||
| 98 | 103 | // Shows reports whether the scene draws the surface of id: only its | |
| 99 | 104 | // 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. | |
| 105 | + | // not drawn. WorkspaceClip also excludes off-viewport windows, TileClip | |
| 106 | + | // tiles under a panel; OverLayers popups remain output-wide. | |
| 102 | 107 | func (s Scene) Shows(id WindowID) bool { | |
| 103 | 108 | for _, w := range s.Windows { | |
| 104 | 109 | 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 | + | (w.OverLayers || s.WorkspaceClip == (Rect{}) || w.Rect.Overlaps(s.WorkspaceClip)) && | |
| 111 | + | (!w.Tile() || s.TileClip == (Rect{}) || w.Rect.Overlaps(s.TileClip)) { | |
| 106 | 112 | return true | |
| 107 | 113 | } | |
| 108 | 114 | } | |
| @@ -180,3 +186,10 @@ type SceneWindow struct { | |||
| 180 | 186 | // overlay layers. Window popups stay with the windows, under them. | |
| 181 | 187 | OverLayers bool | |
| 182 | 188 | } | |
| 189 | + | ||
| 190 | + | // Tile reports whether w is a tile: laid out in the usable area and | |
| 191 | + | // clipped to Scene.TileClip. Floats, fullscreen windows, overview previews | |
| 192 | + | // and popups are placed against the whole viewport. | |
| 193 | + | func (w SceneWindow) Tile() bool { | |
| 194 | + | return !w.Floating && !w.Fullscreen && !w.Popup && w.Preview == 0 | |
| 195 | + | } | |
| @@ -27,6 +27,11 @@ type Scene struct { | |||||
| 27 | // in logical output coordinates. Zero inherits the full output. Layers | 27 | // 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 Rect | 29 | 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 | // Dim darkens the background, bottom layers and tiles (tile lines | 35 | // Dim darkens the background, bottom layers and tiles (tile lines | ||
| 31 | // included) inside WorkspaceClip when set, with black at this opacity, | 36 | // included) inside WorkspaceClip when set, with black at this opacity, | ||
| 32 | // 0 to 1, under the first | 37 | // 0 to 1, under the first | ||
| @@ -74,7 +79,7 @@ func (s Scene) SameAs(o Scene) bool { | |||||
| 74 | s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight || | 79 | s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight || | ||
| 75 | s.Scale != o.Scale || s.Transform != o.Transform || s.Off != o.Off || | 80 | s.Scale != o.Scale || s.Transform != o.Transform || s.Off != o.Off || | ||
| 76 | s.Background != o.Background || s.Border != o.Border || | 81 | s.Background != o.Background || s.Border != o.Border || | ||
| 77 | - | s.WorkspaceClip != o.WorkspaceClip || s.Dim != o.Dim || s.DimBehind != o.DimBehind { | 82 | + | s.WorkspaceClip != o.WorkspaceClip || s.TileClip != o.TileClip || s.Dim != o.Dim || s.DimBehind != o.DimBehind { |
| 78 | return false | 83 | return false | ||
| 79 | } | 84 | } | ||
| 80 | if s.CaptureScene != nil || o.CaptureScene != nil { | 85 | if s.CaptureScene != nil || o.CaptureScene != nil { | ||
| @@ -97,12 +102,13 @@ func (s Scene) SameAs(o Scene) bool { | |||||
| 97 | 102 | ||||
| 98 | // Shows reports whether the scene draws the surface of id: only its | 103 | // 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 is | 104 | // content changes need a new frame. A window scrolled off the output is | ||
| 100 | - | // not drawn. WorkspaceClip also excludes off-viewport windows; OverLayers | 105 | + | // not drawn. WorkspaceClip also excludes off-viewport windows, TileClip |
| 101 | - | // popups remain output-wide. | 106 | + | // tiles under a panel; OverLayers popups remain output-wide. |
| 102 | func (s Scene) Shows(id WindowID) bool { | 107 | func (s Scene) Shows(id WindowID) bool { | ||
| 103 | for _, w := range s.Windows { | 108 | for _, w := range s.Windows { | ||
| 104 | if w.ID == id && !w.Hidden && w.Rect.Overlaps(Rect{W: s.OutputWidth, H: s.OutputHeight}) && | 109 | 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 | + | (w.OverLayers || s.WorkspaceClip == (Rect{}) || w.Rect.Overlaps(s.WorkspaceClip)) && |
| 111 | + | (!w.Tile() || s.TileClip == (Rect{}) || w.Rect.Overlaps(s.TileClip)) { | |||
| 106 | return true | 112 | return true | ||
| 107 | } | 113 | } | ||
| 108 | } | 114 | } | ||
| @@ -180,3 +186,10 @@ type SceneWindow struct { | |||||
| 180 | // overlay layers. Window popups stay with the windows, under them. | 186 | // overlay layers. Window popups stay with the windows, under them. | ||
| 181 | OverLayers bool | 187 | OverLayers bool | ||
| 182 | } | 188 | } | ||
| 189 | + | ||||
| 190 | + | // Tile reports whether w is a tile: laid out in the usable area and | |||
| 191 | + | // clipped to Scene.TileClip. Floats, fullscreen windows, overview previews | |||
| 192 | + | // and popups are placed against the whole viewport. | |||
| 193 | + | func (w SceneWindow) Tile() bool { | |||
| 194 | + | return !w.Floating && !w.Fullscreen && !w.Popup && w.Preview == 0 | |||
| 195 | + | } | |||
M internal/ports/scene_same_test.go +2 -1
| @@ -48,6 +48,7 @@ func TestSceneSameAs(t *testing.T) { | |||
| 48 | 48 | "scale": func(s *Scene) { s.Scale = 2 }, | |
| 49 | 49 | "off": func(s *Scene) { s.Off = true }, | |
| 50 | 50 | "clip": func(s *Scene) { s.WorkspaceClip.W = 1 }, | |
| 51 | + | "tile clip": func(s *Scene) { s.TileClip.H = 1 }, | |
| 51 | 52 | "dim": func(s *Scene) { s.Dim = 0.5 }, | |
| 52 | 53 | "dim behind": func(s *Scene) { s.DimBehind = true }, | |
| 53 | 54 | "separator": func(s *Scene) { s.Separators[0].Active = true }, | |
| @@ -82,7 +83,7 @@ func TestSceneSameAs(t *testing.T) { | |||
| 82 | 83 | // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field: | |
| 83 | 84 | // update Scene.SameAs, then this count. | |
| 84 | 85 | func TestSceneSameAsCoversEveryField(t *testing.T) { | |
| 85 | - | const fields = 20 | |
| 86 | + | const fields = 21 | |
| 86 | 87 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))), | |
| 87 | 88 | "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count") | |
| 88 | 89 | } | |
| @@ -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 clip": func(s *Scene) { s.TileClip.H = 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 = 20 | 86 | + | 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/workspace_clip_test.go +23 -0
| @@ -16,3 +16,26 @@ func TestSceneShowsWorkspaceClip(t *testing.T) { | |||
| 16 | 16 | t.Fatal("inherited workspace excluded surface") | |
| 17 | 17 | } | |
| 18 | 18 | } | |
| 19 | + | ||
| 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) { | |
| 23 | + | under := Rect{Y: 80, W: 50, H: 80} | |
| 24 | + | s := Scene{OutputWidth: 100, OutputHeight: 100, TileClip: Rect{W: 100, H: 80}, Windows: []SceneWindow{ | |
| 25 | + | {ID: 1, Rect: Rect{W: 50, H: 80}}, | |
| 26 | + | {ID: 2, Rect: under}, | |
| 27 | + | {ID: 3, Rect: under, Floating: true}, | |
| 28 | + | {ID: 4, Rect: under, Fullscreen: true}, | |
| 29 | + | {ID: 5, Rect: under, Preview: 0.5}, | |
| 30 | + | {ID: 6, Rect: under, Popup: true}, | |
| 31 | + | }} | |
| 32 | + | for id, want := range map[WindowID]bool{1: true, 2: false, 3: true, 4: true, 5: true, 6: true} { | |
| 33 | + | if got := s.Shows(id); got != want { | |
| 34 | + | t.Errorf("window %d shown %v, want %v", id, got, want) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | s.TileClip = Rect{} | |
| 38 | + | if !s.Shows(2) { | |
| 39 | + | t.Fatal("tile under no clip not shown") | |
| 40 | + | } | |
| 41 | + | } | |
| @@ -16,3 +16,26 @@ 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 TileClip) is not drawn; a float, a | |||
| 21 | + | // fullscreen window, a preview or a popup there still is. | |||
| 22 | + | func TestSceneShowsTileClip(t *testing.T) { | |||
| 23 | + | under := Rect{Y: 80, W: 50, H: 80} | |||
| 24 | + | s := Scene{OutputWidth: 100, OutputHeight: 100, TileClip: Rect{W: 100, H: 80}, Windows: []SceneWindow{ | |||
| 25 | + | {ID: 1, Rect: Rect{W: 50, H: 80}}, | |||
| 26 | + | {ID: 2, Rect: under}, | |||
| 27 | + | {ID: 3, Rect: under, Floating: true}, | |||
| 28 | + | {ID: 4, Rect: under, Fullscreen: true}, | |||
| 29 | + | {ID: 5, Rect: under, Preview: 0.5}, | |||
| 30 | + | {ID: 6, Rect: under, Popup: true}, | |||
| 31 | + | }} | |||
| 32 | + | for id, want := range map[WindowID]bool{1: true, 2: false, 3: true, 4: true, 5: true, 6: true} { | |||
| 33 | + | if got := s.Shows(id); got != want { | |||
| 34 | + | t.Errorf("window %d shown %v, want %v", id, got, want) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | s.TileClip = Rect{} | |||
| 38 | + | if !s.Shows(2) { | |||
| 39 | + | t.Fatal("tile under no clip not shown") | |||
| 40 | + | } | |||
| 41 | + | } | |||