refactor(drm): decide drawn windows from Scene.Draws
The overlay, scanout and VRR checks use Scene.Draws instead of their own Hidden and size tests, so a tile the scene does not draw neither takes nor blocks a plane. TileInset's doc covers size overrides. Tests cover a sized workspace's tile area, scanout past an undrawn tile and the cascade scroll back under the panel.
- commit
- 42b53c5b18cc01af71341b1f324d6018be8664c2
- author
- brice · · 2026-10-08 14:59 UTC
- parent
- 5077157
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +52 -18
M internal/adapters/drm/overlay.go +4 -7
| @@ -76,12 +76,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 76 | 76 | var mode colorMode | |
| 77 | 77 | for i := range s.Windows { | |
| 78 | 78 | w := &s.Windows[i] | |
| 79 | - | if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 { | |
| 80 | - | continue | |
| 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. | |
| 79 | + | if !s.Draws(*w) { | |
| 80 | + | // Not drawn (a tile wholly under a panel included): neither | |
| 81 | + | // a candidate nor over one. | |
| 85 | 82 | continue | |
| 86 | 83 | } | |
| 87 | 84 | if w.Popup { | |
| @@ -109,7 +106,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 109 | 106 | } | |
| 110 | 107 | if pick != nil { | |
| 111 | 108 | for i := range s.Windows { | |
| 112 | - | if w := &s.Windows[i]; w.Popup && !w.Hidden && w.Rect.Overlaps(pick.Rect) { | |
| 109 | + | if w := &s.Windows[i]; w.Popup && s.Draws(*w) && w.Rect.Overlaps(pick.Rect) { | |
| 113 | 110 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "window_above" | |
| 114 | 111 | } | |
| 115 | 112 | } | |
| @@ -76,12 +76,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 76 | var mode colorMode | 76 | var mode colorMode | ||
| 77 | for i := range s.Windows { | 77 | for i := range s.Windows { | ||
| 78 | w := &s.Windows[i] | 78 | w := &s.Windows[i] | ||
| 79 | - | if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 { | 79 | + | if !s.Draws(*w) { |
| 80 | - | continue | 80 | + | // Not drawn (a tile wholly under a panel included): neither |
| 81 | - | } | 81 | + | // a candidate nor over one. |
| 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 | 82 | continue | ||
| 86 | } | 83 | } | ||
| 87 | if w.Popup { | 84 | if w.Popup { | ||
| @@ -109,7 +106,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 109 | } | 106 | } | ||
| 110 | if pick != nil { | 107 | if pick != nil { | ||
| 111 | for i := range s.Windows { | 108 | for i := range s.Windows { | ||
| 112 | - | if w := &s.Windows[i]; w.Popup && !w.Hidden && w.Rect.Overlaps(pick.Rect) { | 109 | + | if w := &s.Windows[i]; w.Popup && s.Draws(*w) && w.Rect.Overlaps(pick.Rect) { |
| 113 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "window_above" | 110 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "window_above" | ||
| 114 | } | 111 | } | ||
| 115 | } | 112 | } | ||
M internal/adapters/drm/overlay_test.go +11 -0
| @@ -83,6 +83,17 @@ func TestOverlayTileInset(t *testing.T) { | |||
| 83 | 83 | } | |
| 84 | 84 | } | |
| 85 | 85 | ||
| 86 | + | // A fullscreen window is scanned out when the only other window is a tile | |
| 87 | + | // the scene does not draw (no room left by the panels). | |
| 88 | + | func TestScanoutIgnoresUndrawnTile(t *testing.T) { | |
| 89 | + | s, contents := fullscreenScene() | |
| 90 | + | s.TileInset = ports.Insets{Bottom: 100} | |
| 91 | + | s.Windows = append([]ports.SceneWindow{{ID: 2, Rect: ports.Rect{W: 200, H: 100}}}, s.Windows...) | |
| 92 | + | if _, reason := scanoutCandidate(s, contents, 200, 100, nil); reason != "" { | |
| 93 | + | t.Fatalf("undrawn tile blocked scanout: %q", reason) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 86 | 97 | func TestOverlayCandidate(t *testing.T) { | |
| 87 | 98 | s, c := overlayScene() | |
| 88 | 99 | if w, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" || w.ID != 2 { | |
| @@ -83,6 +83,17 @@ func TestOverlayTileInset(t *testing.T) { | |||||
| 83 | } | 83 | } | ||
| 84 | } | 84 | } | ||
| 85 | 85 | ||||
| 86 | + | // A fullscreen window is scanned out when the only other window is a tile | |||
| 87 | + | // the scene does not draw (no room left by the panels). | |||
| 88 | + | func TestScanoutIgnoresUndrawnTile(t *testing.T) { | |||
| 89 | + | s, contents := fullscreenScene() | |||
| 90 | + | s.TileInset = ports.Insets{Bottom: 100} | |||
| 91 | + | s.Windows = append([]ports.SceneWindow{{ID: 2, Rect: ports.Rect{W: 200, H: 100}}}, s.Windows...) | |||
| 92 | + | if _, reason := scanoutCandidate(s, contents, 200, 100, nil); reason != "" { | |||
| 93 | + | t.Fatalf("undrawn tile blocked scanout: %q", reason) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 86 | func TestOverlayCandidate(t *testing.T) { | 97 | func TestOverlayCandidate(t *testing.T) { | ||
| 87 | s, c := overlayScene() | 98 | s, c := overlayScene() | ||
| 88 | if w, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" || w.ID != 2 { | 99 | if w, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" || w.ID != 2 { | ||
M internal/adapters/drm/scanout.go +1 -1
| @@ -100,7 +100,7 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 100 | 100 | var full *ports.SceneWindow | |
| 101 | 101 | for i := range s.Windows { | |
| 102 | 102 | win := &s.Windows[i] | |
| 103 | - | if win.Hidden || win.Rect.W <= 0 || win.Rect.H <= 0 { | |
| 103 | + | if !s.Draws(*win) { | |
| 104 | 104 | continue | |
| 105 | 105 | } | |
| 106 | 106 | if !win.Fullscreen || !covers(&s, win) || full != nil { | |
| @@ -100,7 +100,7 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 100 | var full *ports.SceneWindow | 100 | var full *ports.SceneWindow | ||
| 101 | for i := range s.Windows { | 101 | for i := range s.Windows { | ||
| 102 | win := &s.Windows[i] | 102 | win := &s.Windows[i] | ||
| 103 | - | if win.Hidden || win.Rect.W <= 0 || win.Rect.H <= 0 { | 103 | + | if !s.Draws(*win) { |
| 104 | continue | 104 | continue | ||
| 105 | } | 105 | } | ||
| 106 | if !win.Fullscreen || !covers(&s, win) || full != nil { | 106 | if !win.Fullscreen || !covers(&s, win) || full != nil { | ||
M internal/adapters/drm/vrr.go +1 -1
| @@ -68,7 +68,7 @@ func (o *Output) wantContent(s ports.Scene, surfaces map[ports.WindowID]ports.Su | |||
| 68 | 68 | var full *ports.SceneWindow | |
| 69 | 69 | for i := range s.Windows { | |
| 70 | 70 | w := &s.Windows[i] | |
| 71 | - | if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 { | |
| 71 | + | if !s.Draws(*w) { | |
| 72 | 72 | continue | |
| 73 | 73 | } | |
| 74 | 74 | if !w.Fullscreen || !covers(&s, w) || full != nil { | |
| @@ -68,7 +68,7 @@ func (o *Output) wantContent(s ports.Scene, surfaces map[ports.WindowID]ports.Su | |||||
| 68 | var full *ports.SceneWindow | 68 | var full *ports.SceneWindow | ||
| 69 | for i := range s.Windows { | 69 | for i := range s.Windows { | ||
| 70 | w := &s.Windows[i] | 70 | w := &s.Windows[i] | ||
| 71 | - | if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 { | 71 | + | if !s.Draws(*w) { |
| 72 | continue | 72 | continue | ||
| 73 | } | 73 | } | ||
| 74 | if !w.Fullscreen || !covers(&s, w) || full != nil { | 74 | if !w.Fullscreen || !covers(&s, w) || full != nil { | ||
M internal/core/core.go +8 -0
| @@ -476,6 +476,14 @@ func (v viewport) area(p Placement) Rect { | |||
| 476 | 476 | return v.usable | |
| 477 | 477 | } | |
| 478 | 478 | ||
| 479 | + | // hasRoom reports whether p's area can show anything: panels can leave | |
| 480 | + | // tiles no room at all. Unlike shows, it ignores where p is, so a window | |
| 481 | + | // still sliding in counts. | |
| 482 | + | func hasRoom(v viewport, p Placement) bool { | |
| 483 | + | a := v.area(p) | |
| 484 | + | return a.W > 0 && a.H > 0 | |
| 485 | + | } | |
| 486 | + | ||
| 479 | 487 | // shows reports whether the user sees p: not hidden, and overlapping its | |
| 480 | 488 | // area. | |
| 481 | 489 | func (v viewport) shows(p Placement) bool { | |
| @@ -476,6 +476,14 @@ func (v viewport) area(p Placement) Rect { | |||||
| 476 | return v.usable | 476 | return v.usable | ||
| 477 | } | 477 | } | ||
| 478 | 478 | ||||
| 479 | + | // hasRoom reports whether p's area can show anything: panels can leave | |||
| 480 | + | // tiles no room at all. Unlike shows, it ignores where p is, so a window | |||
| 481 | + | // still sliding in counts. | |||
| 482 | + | func hasRoom(v viewport, p Placement) bool { | |||
| 483 | + | a := v.area(p) | |||
| 484 | + | return a.W > 0 && a.H > 0 | |||
| 485 | + | } | |||
| 486 | + | ||||
| 479 | // shows reports whether the user sees p: not hidden, and overlapping its | 487 | // shows reports whether the user sees p: not hidden, and overlapping its | ||
| 480 | // area. | 488 | // area. | ||
| 481 | func (v viewport) shows(p Placement) bool { | 489 | func (v viewport) shows(p Placement) bool { | ||
M internal/core/scene.go +1 -4
| @@ -89,10 +89,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||
| 89 | 89 | } | |
| 90 | 90 | continue | |
| 91 | 91 | } | |
| 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 { | |
| 92 | + | if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open && hasRoom(view, p) { | |
| 96 | 93 | drawable = true | |
| 97 | 94 | if p.ID == c.pulse.id { | |
| 98 | 95 | sw.FocusEffect = pulse | |
| @@ -89,10 +89,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||||
| 89 | } | 89 | } | ||
| 90 | continue | 90 | continue | ||
| 91 | } | 91 | } | ||
| 92 | - | // A window sliding in still waits for its pulse; one with no room | 92 | + | if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open && hasRoom(view, p) { |
| 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 { | |||
| 96 | drawable = true | 93 | drawable = true | ||
| 97 | if p.ID == c.pulse.id { | 94 | if p.ID == c.pulse.id { | ||
| 98 | sw.FocusEffect = pulse | 95 | sw.FocusEffect = pulse | ||
M internal/core/viewport_test.go +20 -0
| @@ -122,6 +122,8 @@ func TestConfigureVisibleCascadeUnderPanel(t *testing.T) { | |||
| 122 | 122 | if c.visible(3) || c.visible(10) || c.pointer != 0 { | |
| 123 | 123 | t.Fatalf("tile under the panel: visible %v, popup %v, pointer %d", c.visible(3), c.visible(10), c.pointer) | |
| 124 | 124 | } | |
| 125 | + | delete(c.popups, 10) | |
| 126 | + | c.popupOrder = slices.DeleteFunc(c.popupOrder, func(id WindowID) bool { return id == 10 }) | |
| 125 | 127 | // The overview lays previews over the whole output: no inset. | |
| 126 | 128 | m.ToggleOverview() | |
| 127 | 129 | if s := check(nil); s.TileInset != (ports.Insets{}) { | |
| @@ -131,7 +133,11 @@ func TestConfigureVisibleCascadeUnderPanel(t *testing.T) { | |||
| 131 | 133 | // Its band scrolled in, the first band is under nothing but off screen. | |
| 132 | 134 | w.FocusID(3) | |
| 133 | 135 | check(map[WindowID]bool{1: false, 2: false, 3: true}) | |
| 136 | + | // Scrolled back, its band is under the panel again. | |
| 137 | + | w.FocusID(1) | |
| 138 | + | check(map[WindowID]bool{1: true, 3: false}) | |
| 134 | 139 | // Fullscreen covers the panel: always seen. | |
| 140 | + | w.FocusID(3) | |
| 135 | 141 | w.ToggleFullscreen() | |
| 136 | 142 | check(map[WindowID]bool{3: true}) | |
| 137 | 143 | } | |
| @@ -200,6 +206,20 @@ func TestNoUsableAreaDrawsNoTile(t *testing.T) { | |||
| 200 | 206 | } | |
| 201 | 207 | } | |
| 202 | 208 | ||
| 209 | + | // A sized workspace with a panel: the scene's tile area is the workspace's | |
| 210 | + | // usable area, inside its viewport. | |
| 211 | + | func TestTileAreaSizedWorkspace(t *testing.T) { | |
| 212 | + | c, check := viewportCore(t, func(cfg *ports.Config) { cfg.Layout.MaxColumns = 1 }, Rect{W: 100, H: 70}) | |
| 213 | + | m := c.cur().mon | |
| 214 | + | m.Current().SetSize(60, 80) | |
| 215 | + | m.AddWindow(1) | |
| 216 | + | scene := check(map[WindowID]bool{1: true}) | |
| 217 | + | a, ok := scene.TileArea() | |
| 218 | + | if u := m.Current().Usable; !ok || a != u || u == m.Current().Output { | |
| 219 | + | t.Fatalf("tile area %+v %v, want usable %+v inside frame %+v", a, ok, u, m.Current().Output) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 203 | 223 | // A column scrolled under a left panel is not seen. | |
| 204 | 224 | func TestConfigureVisibleScrollUnderSidePanel(t *testing.T) { | |
| 205 | 225 | c, check := viewportCore(t, func(cfg *ports.Config) { | |
| @@ -122,6 +122,8 @@ func TestConfigureVisibleCascadeUnderPanel(t *testing.T) { | |||||
| 122 | if c.visible(3) || c.visible(10) || c.pointer != 0 { | 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) | 123 | t.Fatalf("tile under the panel: visible %v, popup %v, pointer %d", c.visible(3), c.visible(10), c.pointer) | ||
| 124 | } | 124 | } | ||
| 125 | + | delete(c.popups, 10) | |||
| 126 | + | c.popupOrder = slices.DeleteFunc(c.popupOrder, func(id WindowID) bool { return id == 10 }) | |||
| 125 | // The overview lays previews over the whole output: no inset. | 127 | // The overview lays previews over the whole output: no inset. | ||
| 126 | m.ToggleOverview() | 128 | m.ToggleOverview() | ||
| 127 | if s := check(nil); s.TileInset != (ports.Insets{}) { | 129 | if s := check(nil); s.TileInset != (ports.Insets{}) { | ||
| @@ -131,7 +133,11 @@ func TestConfigureVisibleCascadeUnderPanel(t *testing.T) { | |||||
| 131 | // Its band scrolled in, the first band is under nothing but off screen. | 133 | // Its band scrolled in, the first band is under nothing but off screen. | ||
| 132 | w.FocusID(3) | 134 | w.FocusID(3) | ||
| 133 | check(map[WindowID]bool{1: false, 2: false, 3: true}) | 135 | check(map[WindowID]bool{1: false, 2: false, 3: true}) | ||
| 136 | + | // Scrolled back, its band is under the panel again. | |||
| 137 | + | w.FocusID(1) | |||
| 138 | + | check(map[WindowID]bool{1: true, 3: false}) | |||
| 134 | // Fullscreen covers the panel: always seen. | 139 | // Fullscreen covers the panel: always seen. | ||
| 140 | + | w.FocusID(3) | |||
| 135 | w.ToggleFullscreen() | 141 | w.ToggleFullscreen() | ||
| 136 | check(map[WindowID]bool{3: true}) | 142 | check(map[WindowID]bool{3: true}) | ||
| 137 | } | 143 | } | ||
| @@ -200,6 +206,20 @@ func TestNoUsableAreaDrawsNoTile(t *testing.T) { | |||||
| 200 | } | 206 | } | ||
| 201 | } | 207 | } | ||
| 202 | 208 | ||||
| 209 | + | // A sized workspace with a panel: the scene's tile area is the workspace's | |||
| 210 | + | // usable area, inside its viewport. | |||
| 211 | + | func TestTileAreaSizedWorkspace(t *testing.T) { | |||
| 212 | + | c, check := viewportCore(t, func(cfg *ports.Config) { cfg.Layout.MaxColumns = 1 }, Rect{W: 100, H: 70}) | |||
| 213 | + | m := c.cur().mon | |||
| 214 | + | m.Current().SetSize(60, 80) | |||
| 215 | + | m.AddWindow(1) | |||
| 216 | + | scene := check(map[WindowID]bool{1: true}) | |||
| 217 | + | a, ok := scene.TileArea() | |||
| 218 | + | if u := m.Current().Usable; !ok || a != u || u == m.Current().Output { | |||
| 219 | + | t.Fatalf("tile area %+v %v, want usable %+v inside frame %+v", a, ok, u, m.Current().Output) | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | ||||
| 203 | // A column scrolled under a left panel is not seen. | 223 | // A column scrolled under a left panel is not seen. | ||
| 204 | func TestConfigureVisibleScrollUnderSidePanel(t *testing.T) { | 224 | func TestConfigureVisibleScrollUnderSidePanel(t *testing.T) { | ||
| 205 | c, check := viewportCore(t, func(cfg *ports.Config) { | 225 | c, check := viewportCore(t, func(cfg *ports.Config) { | ||
M internal/ports/scene.go +6 -5
| @@ -27,11 +27,12 @@ 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 | - | // 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). | |
| 30 | + | // TileInset is what exclusive layers (panels) and a workspace size | |
| 31 | + | // override take from the output's edges: tiles (SceneWindow.Tile) and | |
| 32 | + | // their lines are drawn only in what is left, the usable area | |
| 33 | + | // (TileArea). A tile is never drawn over a panel, whatever its layer; | |
| 34 | + | // insets covering the output leave no tile drawn. Zero: the usable | |
| 35 | + | // area is the whole output (and the overview, capture scenes). | |
| 35 | 36 | TileInset Insets | |
| 36 | 37 | // Dim darkens the background, bottom layers and tiles (tile lines | |
| 37 | 38 | // included) inside WorkspaceClip when set, with black at this opacity, | |
| @@ -27,11 +27,12 @@ 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 | - | // TileInset is what exclusive layers (panels) take from the output's | 30 | + | // TileInset is what exclusive layers (panels) and a workspace size |
| 31 | - | // edges. Tiles (SceneWindow.Tile) and their lines are drawn only in | 31 | + | // override take from the output's edges: tiles (SceneWindow.Tile) and |
| 32 | - | // what is left (TileArea), so a tile is never drawn over a panel, | 32 | + | // their lines are drawn only in what is left, the usable area |
| 33 | - | // whatever its layer; insets covering the output leave no tile drawn. | 33 | + | // (TileArea). A tile is never drawn over a panel, whatever its layer; |
| 34 | - | // Zero: no panel (and the overview, capture scenes). | 34 | + | // insets covering the output leave no tile drawn. Zero: the usable |
| 35 | + | // area is the whole output (and the overview, capture scenes). | |||
| 35 | TileInset Insets | 36 | TileInset Insets | ||
| 36 | // Dim darkens the background, bottom layers and tiles (tile lines | 37 | // Dim darkens the background, bottom layers and tiles (tile lines | ||
| 37 | // included) inside WorkspaceClip when set, with black at this opacity, | 38 | // included) inside WorkspaceClip when set, with black at this opacity, | ||