detached 2b00988

feat(workspace): add centered logical size overrides (#68)

commit
2b0098848df554c2e00723ec45a31f6bb87be361
author
bnema · · 2026-09-30 07:00 UTC
committer
GitHub · · 2026-09-30 07:00 UTC
parent
36cc89e
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
22 files · +912 -71
M docs/config.md +10 -0
@@ -40,6 +40,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
4040 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
4141 | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back |
4242 | `workspace.<name>.monitor` | first output | Home monitor: connector (`DP-2`) or monitor key; guests on another output while home is absent and returns when it reconnects |
43+| `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale |
4344 | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout |
4445 | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` |
4546 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |
@@ -54,6 +55,15 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
5455 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |
5556 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |
5657
58+## Workspace size
59+
60+A workspace inherits its monitor size unless `workspace.<name>.size` sets a logical `WxH`. Its viewport is centered, and columns, maximization, floating windows and fullscreen use that real layout size—not scaled overview previews. Oversized dimensions are limited to the monitor; reserved layer zones still reduce usable space. Monitor mode and scale stay unchanged. Omit `size` or use `inherit` to restore the default. Workspace transitions involving a smaller viewport switch without the vertical slide, keeping scrolling columns inside their frame. Fullscreen inside a smaller viewport uses composition rather than output-covering direct scanout, so fullscreen-game VRR/tearing eligibility does not apply.
61+
62+```ini
63+workspace.presentation.size = 1920x1080
64+bind.Cmd+p = workspace presentation
65+```
66+
5767 ## Touchpad
5868
5969 A three-finger swipe follows the fingers:
@@ -40,6 +40,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
40 | `focus.follow-move` | `off` | Follow a column moved to another workspace |40 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
41 | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back |41 | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back |
42 | `workspace.<name>.monitor` | first output | Home monitor: connector (`DP-2`) or monitor key; guests on another output while home is absent and returns when it reconnects |42 | `workspace.<name>.monitor` | first output | Home monitor: connector (`DP-2`) or monitor key; guests on another output while home is absent and returns when it reconnects |
43+| `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale |
43 | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout |44 | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout |
44 | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` |45 | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` |
45 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |46 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |
@@ -54,6 +55,15 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
54 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |55 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |
55 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |56 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |
56 57
58+## Workspace size
59+
60+A workspace inherits its monitor size unless `workspace.<name>.size` sets a logical `WxH`. Its viewport is centered, and columns, maximization, floating windows and fullscreen use that real layout size—not scaled overview previews. Oversized dimensions are limited to the monitor; reserved layer zones still reduce usable space. Monitor mode and scale stay unchanged. Omit `size` or use `inherit` to restore the default. Workspace transitions involving a smaller viewport switch without the vertical slide, keeping scrolling columns inside their frame. Fullscreen inside a smaller viewport uses composition rather than output-covering direct scanout, so fullscreen-game VRR/tearing eligibility does not apply.
61+
62+```ini
63+workspace.presentation.size = 1920x1080
64+bind.Cmd+p = workspace presentation
65+```
66+
57 ## Touchpad67 ## Touchpad
58 68
59 A three-finger swipe follows the fingers:69 A three-finger swipe follows the fingers:
M internal/adapters/config/config.go +11 -1
@@ -713,12 +713,22 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
713713 return fmt.Errorf("needs a connector (DP-2) or a monitor key")
714714 }
715715 w.Monitor = v
716+ case "size":
717+ if v == "inherit" {
718+ w.Size = [2]int{}
719+ return nil
720+ }
721+ width, height, _, err := ParseMode(v)
722+ if err != nil || width == 0 || strings.Contains(v, "@") {
723+ return fmt.Errorf("must be inherit or WxH in logical pixels")
724+ }
725+ w.Size = [2]int{width, height}
716726 case "max-columns", "overflow":
717727 return setLayoutRule(&w.LayoutRules, field, v)
718728 default:
719729 n, ok := strings.CutPrefix(field, "column.")
720730 if !ok {
721- return fmt.Errorf("unknown key (monitor, max-columns, overflow, column.N)")
731+ return fmt.Errorf("unknown key (monitor, size, max-columns, overflow, column.N)")
722732 }
723733 index, err := strconv.Atoi(n)
724734 if err != nil || index < 1 || index > 16 {
@@ -713,12 +713,22 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
713 return fmt.Errorf("needs a connector (DP-2) or a monitor key")713 return fmt.Errorf("needs a connector (DP-2) or a monitor key")
714 }714 }
715 w.Monitor = v715 w.Monitor = v
716+ case "size":
717+ if v == "inherit" {
718+ w.Size = [2]int{}
719+ return nil
720+ }
721+ width, height, _, err := ParseMode(v)
722+ if err != nil || width == 0 || strings.Contains(v, "@") {
723+ return fmt.Errorf("must be inherit or WxH in logical pixels")
724+ }
725+ w.Size = [2]int{width, height}
716 case "max-columns", "overflow":726 case "max-columns", "overflow":
717 return setLayoutRule(&w.LayoutRules, field, v)727 return setLayoutRule(&w.LayoutRules, field, v)
718 default:728 default:
719 n, ok := strings.CutPrefix(field, "column.")729 n, ok := strings.CutPrefix(field, "column.")
720 if !ok {730 if !ok {
721- return fmt.Errorf("unknown key (monitor, max-columns, overflow, column.N)")731+ return fmt.Errorf("unknown key (monitor, size, max-columns, overflow, column.N)")
722 }732 }
723 index, err := strconv.Atoi(n)733 index, err := strconv.Atoi(n)
724 if err != nil || index < 1 || index > 16 {734 if err != nil || index < 1 || index > 16 {
A internal/adapters/config/workspace_size_test.go +45 -0
@@ -0,0 +1,45 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+func TestWorkspaceSize(t *testing.T) {
10+ for _, tc := range []struct {
11+ value string
12+ want [2]int
13+ valid bool
14+ }{
15+ {"1920x1080", [2]int{1920, 1080}, true},
16+ {"inherit", [2]int{}, true},
17+ {"auto", [2]int{}, false},
18+ {"0x1080", [2]int{}, false},
19+ {"1920x0", [2]int{}, false},
20+ {"1920x1080@60", [2]int{}, false},
21+ {"-1x1080", [2]int{}, false},
22+ {"1920", [2]int{}, false},
23+ {"999999999999999999999x1080", [2]int{}, false},
24+ } {
25+ t.Run(tc.value, func(t *testing.T) {
26+ var ws ports.WorkspaceConfig
27+ err := setWorkspace(&ws, "size", tc.value)
28+ if (err == nil) != tc.valid || ws.Size != tc.want {
29+ t.Fatalf("size %v, err %v; want %v valid=%v", ws.Size, err, tc.want, tc.valid)
30+ }
31+ })
32+ }
33+}
34+
35+func TestWorkspaceSizeParserAndInheritance(t *testing.T) {
36+ cfg, warnings := parseString(t, "workspace.presentation.size = 1920x1080\nbind.Cmd+p = workspace presentation\n")
37+ if len(warnings) != 0 || len(cfg.Workspaces) != 1 || cfg.Workspaces[0].Size != [2]int{1920, 1080} {
38+ t.Fatalf("config %+v, warnings %v", cfg.Workspaces, warnings)
39+ }
40+ cfg, warnings = parseString(t, "workspace.presentation.size = 1920x1080\nworkspace.presentation.size = inherit\nbind.Cmd+p = workspace presentation\n")
41+ // Duplicate keys may warn; the final value must restore inheritance.
42+ if len(cfg.Workspaces) != 1 || cfg.Workspaces[0].Size != [2]int{} {
43+ t.Fatalf("inherit %+v, warnings %v", cfg.Workspaces, warnings)
44+ }
45+}
@@ -0,0 +1,45 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+func TestWorkspaceSize(t *testing.T) {
10+ for _, tc := range []struct {
11+ value string
12+ want [2]int
13+ valid bool
14+ }{
15+ {"1920x1080", [2]int{1920, 1080}, true},
16+ {"inherit", [2]int{}, true},
17+ {"auto", [2]int{}, false},
18+ {"0x1080", [2]int{}, false},
19+ {"1920x0", [2]int{}, false},
20+ {"1920x1080@60", [2]int{}, false},
21+ {"-1x1080", [2]int{}, false},
22+ {"1920", [2]int{}, false},
23+ {"999999999999999999999x1080", [2]int{}, false},
24+ } {
25+ t.Run(tc.value, func(t *testing.T) {
26+ var ws ports.WorkspaceConfig
27+ err := setWorkspace(&ws, "size", tc.value)
28+ if (err == nil) != tc.valid || ws.Size != tc.want {
29+ t.Fatalf("size %v, err %v; want %v valid=%v", ws.Size, err, tc.want, tc.valid)
30+ }
31+ })
32+ }
33+}
34+
35+func TestWorkspaceSizeParserAndInheritance(t *testing.T) {
36+ cfg, warnings := parseString(t, "workspace.presentation.size = 1920x1080\nbind.Cmd+p = workspace presentation\n")
37+ if len(warnings) != 0 || len(cfg.Workspaces) != 1 || cfg.Workspaces[0].Size != [2]int{1920, 1080} {
38+ t.Fatalf("config %+v, warnings %v", cfg.Workspaces, warnings)
39+ }
40+ cfg, warnings = parseString(t, "workspace.presentation.size = 1920x1080\nworkspace.presentation.size = inherit\nbind.Cmd+p = workspace presentation\n")
41+ // Duplicate keys may warn; the final value must restore inheritance.
42+ if len(cfg.Workspaces) != 1 || cfg.Workspaces[0].Size != [2]int{} {
43+ t.Fatalf("inherit %+v, warnings %v", cfg.Workspaces, warnings)
44+ }
45+}
M internal/adapters/drm/overlay.go +5 -0
@@ -103,6 +103,11 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
103103 // The overlay shows the buffer alone; the lines would go with it.
104104 return ports.SceneWindow{}, ports.SurfaceContent{}, "border"
105105 }
106+ if clip := s.WorkspaceClip; clip != (ports.Rect{}) &&
107+ (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) {
108+ // The plane cannot apply the workspace's logical viewport crop.
109+ return ports.SceneWindow{}, ports.SurfaceContent{}, "workspace_clip"
110+ }
106111 c := surfaces[pick.ID]
107112 pw, ph := float64(pick.Rect.W)*scale, float64(pick.Rect.H)*scale
108113 if float64(c.Width) != pw || float64(c.Height) != ph || c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0) {
@@ -103,6 +103,11 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
103 // The overlay shows the buffer alone; the lines would go with it.103 // The overlay shows the buffer alone; the lines would go with it.
104 return ports.SceneWindow{}, ports.SurfaceContent{}, "border"104 return ports.SceneWindow{}, ports.SurfaceContent{}, "border"
105 }105 }
106+ if clip := s.WorkspaceClip; clip != (ports.Rect{}) &&
107+ (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) {
108+ // The plane cannot apply the workspace's logical viewport crop.
109+ return ports.SceneWindow{}, ports.SurfaceContent{}, "workspace_clip"
110+ }
106 c := surfaces[pick.ID]111 c := surfaces[pick.ID]
107 pw, ph := float64(pick.Rect.W)*scale, float64(pick.Rect.H)*scale112 pw, ph := float64(pick.Rect.W)*scale, float64(pick.Rect.H)*scale
108 if float64(c.Width) != pw || float64(c.Height) != ph || c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0) {113 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 +23 -0
@@ -40,6 +40,29 @@ func overlayScene() (ports.Scene, map[ports.WindowID]ports.SurfaceContent) {
4040 }
4141 }
4242
43+func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) {
44+ s, contents := fullscreenScene()
45+ s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100}
46+ if _, reason := scanoutCandidate(s, contents, 200, 100); reason != "workspace_clip" {
47+ t.Fatalf("fullscreen bypassed clip: %q", reason)
48+ }
49+ if fullscreenShown(&s) {
50+ t.Fatal("clipped fullscreen counted as output-covering")
51+ }
52+}
53+
54+func TestOverlayWorkspaceClip(t *testing.T) {
55+ s, contents := overlayScene()
56+ s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100}
57+ if _, _, reason := overlayCandidate(s, contents); reason != "workspace_clip" {
58+ t.Fatalf("overflowing workspace overlay: %q", reason)
59+ }
60+ s.WorkspaceClip = ports.Rect{X: 100, W: 100, H: 100}
61+ if win, _, reason := overlayCandidate(s, contents); reason != "" || win.ID != 2 {
62+ t.Fatalf("contained workspace overlay: %v %q", win.ID, reason)
63+ }
64+}
65+
4366 func TestOverlayCandidate(t *testing.T) {
4467 s, c := overlayScene()
4568 if w, _, reason := overlayCandidate(s, c); reason != "" || w.ID != 2 {
@@ -40,6 +40,29 @@ func overlayScene() (ports.Scene, map[ports.WindowID]ports.SurfaceContent) {
40 }40 }
41 }41 }
42 42
43+func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) {
44+ s, contents := fullscreenScene()
45+ s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100}
46+ if _, reason := scanoutCandidate(s, contents, 200, 100); reason != "workspace_clip" {
47+ t.Fatalf("fullscreen bypassed clip: %q", reason)
48+ }
49+ if fullscreenShown(&s) {
50+ t.Fatal("clipped fullscreen counted as output-covering")
51+ }
52+}
53+
54+func TestOverlayWorkspaceClip(t *testing.T) {
55+ s, contents := overlayScene()
56+ s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100}
57+ if _, _, reason := overlayCandidate(s, contents); reason != "workspace_clip" {
58+ t.Fatalf("overflowing workspace overlay: %q", reason)
59+ }
60+ s.WorkspaceClip = ports.Rect{X: 100, W: 100, H: 100}
61+ if win, _, reason := overlayCandidate(s, contents); reason != "" || win.ID != 2 {
62+ t.Fatalf("contained workspace overlay: %v %q", win.ID, reason)
63+ }
64+}
65+
43 func TestOverlayCandidate(t *testing.T) {66 func TestOverlayCandidate(t *testing.T) {
44 s, c := overlayScene()67 s, c := overlayScene()
45 if w, _, reason := overlayCandidate(s, c); reason != "" || w.ID != 2 {68 if w, _, reason := overlayCandidate(s, c); reason != "" || w.ID != 2 {
M internal/adapters/drm/scanout.go +5 -1
@@ -66,7 +66,8 @@ func scanoutRect(c ports.SurfaceContent, w, h int) planeRect {
6666
6767 // covers reports whether win fills the output of scene s.
6868 func covers(s *ports.Scene, win *ports.SceneWindow) bool {
69- return win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight
69+ return (s.WorkspaceClip == (ports.Rect{}) || s.WorkspaceClip == (ports.Rect{W: s.OutputWidth, H: s.OutputHeight})) &&
70+ win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight
7071 }
7172
7273 // fullscreenShown reports whether a visible fullscreen window covers the
@@ -85,6 +86,9 @@ func fullscreenShown(s *ports.Scene) bool {
8586 // directly, or a reason why it cannot. w, h are the output's physical size.
8687 // The plane's formats are checked by scanoutFB.
8788 func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int) (ports.SurfaceContent, string) {
89+ if s.WorkspaceClip != (ports.Rect{}) && s.WorkspaceClip != (ports.Rect{W: s.OutputWidth, H: s.OutputHeight}) {
90+ return ports.SurfaceContent{}, "workspace_clip"
91+ }
8892 var full *ports.SceneWindow
8993 for i := range s.Windows {
9094 win := &s.Windows[i]
@@ -66,7 +66,8 @@ func scanoutRect(c ports.SurfaceContent, w, h int) planeRect {
66 66
67 // covers reports whether win fills the output of scene s.67 // covers reports whether win fills the output of scene s.
68 func covers(s *ports.Scene, win *ports.SceneWindow) bool {68 func covers(s *ports.Scene, win *ports.SceneWindow) bool {
69- return win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight69+ return (s.WorkspaceClip == (ports.Rect{}) || s.WorkspaceClip == (ports.Rect{W: s.OutputWidth, H: s.OutputHeight})) &&
70+ win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight
70 }71 }
71 72
72 // fullscreenShown reports whether a visible fullscreen window covers the73 // fullscreenShown reports whether a visible fullscreen window covers the
@@ -85,6 +86,9 @@ func fullscreenShown(s *ports.Scene) bool {
85 // directly, or a reason why it cannot. w, h are the output's physical size.86 // directly, or a reason why it cannot. w, h are the output's physical size.
86 // The plane's formats are checked by scanoutFB.87 // The plane's formats are checked by scanoutFB.
87 func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int) (ports.SurfaceContent, string) {88 func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int) (ports.SurfaceContent, string) {
89+ if s.WorkspaceClip != (ports.Rect{}) && s.WorkspaceClip != (ports.Rect{W: s.OutputWidth, H: s.OutputHeight}) {
90+ return ports.SurfaceContent{}, "workspace_clip"
91+ }
88 var full *ports.SceneWindow92 var full *ports.SceneWindow
89 for i := range s.Windows {93 for i := range s.Windows {
90 win := &s.Windows[i]94 win := &s.Windows[i]
M internal/adapters/vulkan/scene.go +8 -2
@@ -33,8 +33,14 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
3333 w.scale = 1
3434 }
3535 w.layers(false)
36+ outputBounds := w.bounds
37+ if s.WorkspaceClip != (ports.Rect{}) {
38+ c := s.WorkspaceClip
39+ w.bounds = w.bounds.Intersect(w.physRect(c.X, c.Y, c.W, c.H))
40+ }
3641 w.windows()
3742 w.popups(false)
43+ w.bounds = outputBounds
3844 w.layers(true)
3945 w.popups(true)
4046 r.scratchDraws = w.draws
@@ -251,7 +257,7 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y float64, cl
251257 if near(full.Dx(), sourceW) && near(full.Dy(), sourceH) {
252258 full.Max = full.Min.Add(image.Pt(sourceW, sourceH))
253259 }
254- dst = full.Intersect(w.physRect(clip.Min.X, clip.Min.Y, clip.Dx(), clip.Dy()))
260+ dst = full.Intersect(w.physRect(clip.Min.X, clip.Min.Y, clip.Dx(), clip.Dy())).Intersect(w.bounds)
255261 return full, dst, !dst.Empty()
256262 }
257263
@@ -278,7 +284,7 @@ func (w *sceneWalk) surface(content *ports.SurfaceContent, x, y float64, clip im
278284 // that dst shows. key names the surface, seq its content.
279285 func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceContent, key shmKey, seq uint64, root bool) {
280286 r := w.r
281- rect := dst.Intersect(w.bounds)
287+ rect := dst
282288 if rect.Empty() {
283289 return
284290 }
@@ -33,8 +33,14 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
33 w.scale = 133 w.scale = 1
34 }34 }
35 w.layers(false)35 w.layers(false)
36+ outputBounds := w.bounds
37+ if s.WorkspaceClip != (ports.Rect{}) {
38+ c := s.WorkspaceClip
39+ w.bounds = w.bounds.Intersect(w.physRect(c.X, c.Y, c.W, c.H))
40+ }
36 w.windows()41 w.windows()
37 w.popups(false)42 w.popups(false)
43+ w.bounds = outputBounds
38 w.layers(true)44 w.layers(true)
39 w.popups(true)45 w.popups(true)
40 r.scratchDraws = w.draws46 r.scratchDraws = w.draws
@@ -251,7 +257,7 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y float64, cl
251 if near(full.Dx(), sourceW) && near(full.Dy(), sourceH) {257 if near(full.Dx(), sourceW) && near(full.Dy(), sourceH) {
252 full.Max = full.Min.Add(image.Pt(sourceW, sourceH))258 full.Max = full.Min.Add(image.Pt(sourceW, sourceH))
253 }259 }
254- dst = full.Intersect(w.physRect(clip.Min.X, clip.Min.Y, clip.Dx(), clip.Dy()))260+ dst = full.Intersect(w.physRect(clip.Min.X, clip.Min.Y, clip.Dx(), clip.Dy())).Intersect(w.bounds)
255 return full, dst, !dst.Empty()261 return full, dst, !dst.Empty()
256 }262 }
257 263
@@ -278,7 +284,7 @@ func (w *sceneWalk) surface(content *ports.SurfaceContent, x, y float64, clip im
278 // that dst shows. key names the surface, seq its content.284 // that dst shows. key names the surface, seq its content.
279 func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceContent, key shmKey, seq uint64, root bool) {285 func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceContent, key shmKey, seq uint64, root bool) {
280 r := w.r286 r := w.r
281- rect := dst.Intersect(w.bounds)287+ rect := dst
282 if rect.Empty() {288 if rect.Empty() {
283 return289 return
284 }290 }
A internal/adapters/vulkan/workspace_clip_test.go +77 -0
@@ -0,0 +1,77 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "image/color"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func TestWorkspaceClipWindowsPopupsAndLayers(t *testing.T) {
12+ r, err := New(64, 48)
13+ if err != nil {
14+ t.Skipf("Vulkan unavailable: %v", err)
15+ }
16+ defer r.Close()
17+ red := color.RGBA{255, 0, 0, 255}
18+ green := color.RGBA{0, 255, 0, 255}
19+ contents := map[ports.WindowID]ports.SurfaceContent{1: solidContent(t, 64, 48, red), 2: solidContent(t, 8, 8, green)}
20+ clip := ports.Rect{X: 16, Y: 12, W: 32, H: 24}
21+ scene := ports.Scene{Seq: 1, OutputWidth: 64, OutputHeight: 48, Background: "#000000", WorkspaceClip: clip,
22+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}}},
23+ Layers: []ports.SceneLayer{{ID: 2, Rect: ports.Rect{W: 8, H: 8}, Layer: ports.LayerTop}},
24+ }
25+ if err := render(r, scene, contents); err != nil {
26+ t.Fatal(err)
27+ }
28+ pixels := readPixels(t, r)
29+ for point, want := range map[image.Point]color.RGBA{{0, 0}: green, {10, 10}: {0, 0, 0, 255}, {16, 12}: red, {47, 35}: red, {48, 36}: {0, 0, 0, 255}} {
30+ if got := pixels.RGBAAt(point.X, point.Y); got != want {
31+ t.Errorf("pixel %v=%v, want %v", point, got, want)
32+ }
33+ }
34+ // Content-only damage at the same scene sequence must remain clipped.
35+ changed := contents[1]
36+ changed.Seq++
37+ changed.DamageHistory = []ports.SeqDamage{{Seq: changed.Seq, Full: true}}
38+ if _, err := changed.SHM.File.WriteAt([]byte{255, 255, 255, 255}, 0); err != nil {
39+ t.Fatal(err)
40+ }
41+ contents[1] = changed
42+ if err := render(r, scene, contents); err != nil {
43+ t.Fatal(err)
44+ }
45+ if got := readPixels(t, r).RGBAAt(10, 10); got != (color.RGBA{0, 0, 0, 255}) {
46+ t.Fatalf("damage escaped clip: %v", got)
47+ }
48+ // A popup from a workspace surface is clipped too; input popups over
49+ // layers retain output-wide bounds.
50+ scene.Seq++
51+ scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}, Popup: true}}
52+ if err := render(r, scene, contents); err != nil {
53+ t.Fatal(err)
54+ }
55+ if got := readPixels(t, r).RGBAAt(10, 10); got != (color.RGBA{0, 0, 0, 255}) {
56+ t.Fatalf("popup escaped clip: %v", got)
57+ }
58+ // Popups above layers belong to output-wide controls, not the workspace.
59+ scene.Seq++
60+ scene.Windows[0].OverLayers = true
61+ if err := render(r, scene, contents); err != nil {
62+ t.Fatal(err)
63+ }
64+ if got := readPixels(t, r).RGBAAt(10, 10); got != red {
65+ t.Fatalf("output-wide popup clipped: %v", got)
66+ }
67+ scene.Windows[0].OverLayers = false
68+ // Switching back to inherited geometry repaints the margins.
69+ scene.Seq++
70+ scene.WorkspaceClip = ports.Rect{}
71+ if err := render(r, scene, contents); err != nil {
72+ t.Fatal(err)
73+ }
74+ if got := readPixels(t, r).RGBAAt(10, 10); got != red {
75+ t.Fatalf("stale clip after inheritance: %v", got)
76+ }
77+}
@@ -0,0 +1,77 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "image/color"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func TestWorkspaceClipWindowsPopupsAndLayers(t *testing.T) {
12+ r, err := New(64, 48)
13+ if err != nil {
14+ t.Skipf("Vulkan unavailable: %v", err)
15+ }
16+ defer r.Close()
17+ red := color.RGBA{255, 0, 0, 255}
18+ green := color.RGBA{0, 255, 0, 255}
19+ contents := map[ports.WindowID]ports.SurfaceContent{1: solidContent(t, 64, 48, red), 2: solidContent(t, 8, 8, green)}
20+ clip := ports.Rect{X: 16, Y: 12, W: 32, H: 24}
21+ scene := ports.Scene{Seq: 1, OutputWidth: 64, OutputHeight: 48, Background: "#000000", WorkspaceClip: clip,
22+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}}},
23+ Layers: []ports.SceneLayer{{ID: 2, Rect: ports.Rect{W: 8, H: 8}, Layer: ports.LayerTop}},
24+ }
25+ if err := render(r, scene, contents); err != nil {
26+ t.Fatal(err)
27+ }
28+ pixels := readPixels(t, r)
29+ for point, want := range map[image.Point]color.RGBA{{0, 0}: green, {10, 10}: {0, 0, 0, 255}, {16, 12}: red, {47, 35}: red, {48, 36}: {0, 0, 0, 255}} {
30+ if got := pixels.RGBAAt(point.X, point.Y); got != want {
31+ t.Errorf("pixel %v=%v, want %v", point, got, want)
32+ }
33+ }
34+ // Content-only damage at the same scene sequence must remain clipped.
35+ changed := contents[1]
36+ changed.Seq++
37+ changed.DamageHistory = []ports.SeqDamage{{Seq: changed.Seq, Full: true}}
38+ if _, err := changed.SHM.File.WriteAt([]byte{255, 255, 255, 255}, 0); err != nil {
39+ t.Fatal(err)
40+ }
41+ contents[1] = changed
42+ if err := render(r, scene, contents); err != nil {
43+ t.Fatal(err)
44+ }
45+ if got := readPixels(t, r).RGBAAt(10, 10); got != (color.RGBA{0, 0, 0, 255}) {
46+ t.Fatalf("damage escaped clip: %v", got)
47+ }
48+ // A popup from a workspace surface is clipped too; input popups over
49+ // layers retain output-wide bounds.
50+ scene.Seq++
51+ scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}, Popup: true}}
52+ if err := render(r, scene, contents); err != nil {
53+ t.Fatal(err)
54+ }
55+ if got := readPixels(t, r).RGBAAt(10, 10); got != (color.RGBA{0, 0, 0, 255}) {
56+ t.Fatalf("popup escaped clip: %v", got)
57+ }
58+ // Popups above layers belong to output-wide controls, not the workspace.
59+ scene.Seq++
60+ scene.Windows[0].OverLayers = true
61+ if err := render(r, scene, contents); err != nil {
62+ t.Fatal(err)
63+ }
64+ if got := readPixels(t, r).RGBAAt(10, 10); got != red {
65+ t.Fatalf("output-wide popup clipped: %v", got)
66+ }
67+ scene.Windows[0].OverLayers = false
68+ // Switching back to inherited geometry repaints the margins.
69+ scene.Seq++
70+ scene.WorkspaceClip = ports.Rect{}
71+ if err := render(r, scene, contents); err != nil {
72+ t.Fatal(err)
73+ }
74+ if got := readPixels(t, r).RGBAAt(10, 10); got != red {
75+ t.Fatalf("stale clip after inheritance: %v", got)
76+ }
77+}
A internal/core/constraint_frame_test.go +79 -0
@@ -0,0 +1,79 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// partlyShown has window 2 straddling the left edge of a 100x100 viewport:
11+// its client is x 75..125 of the monitor (1075..1125 globally) and only
12+// 100..125 shows.
13+func partlyShown(t *testing.T) *Core {
14+ t.Helper()
15+ var cfg ports.Config
16+ cfg.Keyboard.CmdKey = "super"
17+ cfg.Layout.MaxColumns = 2
18+ c, err := New(cfg, Channels{
19+ Scenes: make(chan []ports.Scene, 1), Layouts: make(chan ports.Layout, 1), Constraints: make(chan ports.PointerConstraint, 1),
20+ State: make(chan ports.State, 1), Workspaces: make(chan ports.Workspaces, 1), Commands: make(chan ports.ClientCommand, 16),
21+ })
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
26+ s := c.screens[0]
27+ s.x = 1000
28+ s.mon.SetNamed([]NamedWorkspace{{Name: "s", Size: [2]int{100, 100}}})
29+ s.mon.ToggleNamed("s")
30+ for id := WindowID(1); id <= 3; id++ {
31+ s.mon.AddWindow(id)
32+ }
33+ s.mon.Current().ViewX += 25
34+ return c
35+}
36+
37+// A constraint region is window-local: it starts at the whole client's
38+// origin, not at the visible part's, and is then cut to the viewport.
39+func TestConstraintRegionUsesFullClientOrigin(t *testing.T) {
40+ c := partlyShown(t)
41+ full, vis, ok := c.shownClient(2)
42+ if !ok || full != (Rect{X: 1075, Y: 50, W: 50, H: 100}) || vis != (Rect{X: 1100, Y: 50, W: 25, H: 100}) {
43+ t.Fatalf("full %+v visible %+v %v", full, vis, ok)
44+ }
45+ c.constrained = ports.PointerConstrained{ID: 2, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: Rect{X: 30, Y: 10, W: 20, H: 20}}}
46+ c.resolveConstraint()
47+ // Local x 30..50 is global 1105..1125, all inside the visible part.
48+ if want := (Rect{X: 1105, Y: 60, W: 20, H: 20}); c.constraint.Rect != want {
49+ t.Fatalf("region %+v want %+v", c.constraint.Rect, want)
50+ }
51+ // A region straddling the viewport edge is cut to it: local 10..40 is 1085..1115.
52+ c.constrained.PointerConstraint.Rect = Rect{X: 10, Y: 0, W: 30, H: 100}
53+ c.resolveConstraint()
54+ if want := (Rect{X: 1100, Y: 50, W: 15, H: 100}); c.constraint.Rect != want {
55+ t.Fatalf("straddling region %+v want %+v", c.constraint.Rect, want)
56+ }
57+}
58+
59+// A warp target is computed from the whole client origin and refused when it
60+// falls outside the viewport.
61+func TestWarpUsesFullClientOriginAndFrame(t *testing.T) {
62+ c := partlyShown(t)
63+ c.pointer = 2
64+ ctx := context.Background()
65+ // Local 10 is global 1085: the hidden part of the window.
66+ if err := c.warpPointer(ctx, ports.PointerWarp{ID: 2, X: 10, Y: 5}); err != nil {
67+ t.Fatal(err)
68+ }
69+ if c.cursorX != 0 || c.cursorY != 0 {
70+ t.Fatalf("warped into the hidden part: %v,%v", c.cursorX, c.cursorY)
71+ }
72+ // Local 30 is global 1105: shown.
73+ if err := c.warpPointer(ctx, ports.PointerWarp{ID: 2, X: 30, Y: 5}); err != nil {
74+ t.Fatal(err)
75+ }
76+ if c.cursorX != 1105 || c.cursorY != 55 {
77+ t.Fatalf("cursor %v,%v want 1105,55", c.cursorX, c.cursorY)
78+ }
79+}
@@ -0,0 +1,79 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// partlyShown has window 2 straddling the left edge of a 100x100 viewport:
11+// its client is x 75..125 of the monitor (1075..1125 globally) and only
12+// 100..125 shows.
13+func partlyShown(t *testing.T) *Core {
14+ t.Helper()
15+ var cfg ports.Config
16+ cfg.Keyboard.CmdKey = "super"
17+ cfg.Layout.MaxColumns = 2
18+ c, err := New(cfg, Channels{
19+ Scenes: make(chan []ports.Scene, 1), Layouts: make(chan ports.Layout, 1), Constraints: make(chan ports.PointerConstraint, 1),
20+ State: make(chan ports.State, 1), Workspaces: make(chan ports.Workspaces, 1), Commands: make(chan ports.ClientCommand, 16),
21+ })
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
26+ s := c.screens[0]
27+ s.x = 1000
28+ s.mon.SetNamed([]NamedWorkspace{{Name: "s", Size: [2]int{100, 100}}})
29+ s.mon.ToggleNamed("s")
30+ for id := WindowID(1); id <= 3; id++ {
31+ s.mon.AddWindow(id)
32+ }
33+ s.mon.Current().ViewX += 25
34+ return c
35+}
36+
37+// A constraint region is window-local: it starts at the whole client's
38+// origin, not at the visible part's, and is then cut to the viewport.
39+func TestConstraintRegionUsesFullClientOrigin(t *testing.T) {
40+ c := partlyShown(t)
41+ full, vis, ok := c.shownClient(2)
42+ if !ok || full != (Rect{X: 1075, Y: 50, W: 50, H: 100}) || vis != (Rect{X: 1100, Y: 50, W: 25, H: 100}) {
43+ t.Fatalf("full %+v visible %+v %v", full, vis, ok)
44+ }
45+ c.constrained = ports.PointerConstrained{ID: 2, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: Rect{X: 30, Y: 10, W: 20, H: 20}}}
46+ c.resolveConstraint()
47+ // Local x 30..50 is global 1105..1125, all inside the visible part.
48+ if want := (Rect{X: 1105, Y: 60, W: 20, H: 20}); c.constraint.Rect != want {
49+ t.Fatalf("region %+v want %+v", c.constraint.Rect, want)
50+ }
51+ // A region straddling the viewport edge is cut to it: local 10..40 is 1085..1115.
52+ c.constrained.PointerConstraint.Rect = Rect{X: 10, Y: 0, W: 30, H: 100}
53+ c.resolveConstraint()
54+ if want := (Rect{X: 1100, Y: 50, W: 15, H: 100}); c.constraint.Rect != want {
55+ t.Fatalf("straddling region %+v want %+v", c.constraint.Rect, want)
56+ }
57+}
58+
59+// A warp target is computed from the whole client origin and refused when it
60+// falls outside the viewport.
61+func TestWarpUsesFullClientOriginAndFrame(t *testing.T) {
62+ c := partlyShown(t)
63+ c.pointer = 2
64+ ctx := context.Background()
65+ // Local 10 is global 1085: the hidden part of the window.
66+ if err := c.warpPointer(ctx, ports.PointerWarp{ID: 2, X: 10, Y: 5}); err != nil {
67+ t.Fatal(err)
68+ }
69+ if c.cursorX != 0 || c.cursorY != 0 {
70+ t.Fatalf("warped into the hidden part: %v,%v", c.cursorX, c.cursorY)
71+ }
72+ // Local 30 is global 1105: shown.
73+ if err := c.warpPointer(ctx, ports.PointerWarp{ID: 2, X: 30, Y: 5}); err != nil {
74+ t.Fatal(err)
75+ }
76+ if c.cursorX != 1105 || c.cursorY != 55 {
77+ t.Fatalf("cursor %v,%v want 1105,55", c.cursorX, c.cursorY)
78+ }
79+}
M internal/core/core.go +70 -24
@@ -231,11 +231,11 @@ func (c *Core) apply(cfg ports.Config) error {
231231 if err != nil {
232232 return err
233233 }
234- if ws.Name == "" || seen[ws.Name] || ws.MaxColumns < 0 {
234+ if ws.Name == "" || seen[ws.Name] || ws.MaxColumns < 0 || ws.Size[0] < 0 || ws.Size[1] < 0 {
235235 return fmt.Errorf("invalid workspace %q", ws.Name)
236236 }
237237 seen[ws.Name] = true
238- named = append(named, NamedWorkspace{Name: ws.Name, Monitor: ws.Monitor, MaxColumns: ws.MaxColumns, Overflow: o})
238+ named = append(named, NamedWorkspace{Name: ws.Name, Monitor: ws.Monitor, MaxColumns: ws.MaxColumns, Overflow: o, Size: ws.Size})
239239 }
240240 specs, err := parseSlots(cfg.Workspaces)
241241 if err != nil {
@@ -335,7 +335,7 @@ func (c *Core) clientRect(p Placement) Rect {
335335 // onScreen reports whether a placement is drawn on its output o: not
336336 // hidden and not scrolled off.
337337 func onScreen(p Placement, o Rect) bool {
338- return !p.Hidden && p.Rect.Overlaps(Rect{W: o.W, H: o.H})
338+ return !p.Hidden && p.Rect.Overlaps(o)
339339 }
340340
341341 // floatDim is the veil opacity of a layout: dim only when a float is
@@ -449,7 +449,14 @@ func (c *Core) publish(ctx context.Context) error {
449449 for i, sc := range c.screens {
450450 c.seq++
451451 o := sc.mon.Output()
452- scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, 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), Layers: shownLayers(sc)}
452+ // frame is the viewport of the workspace on screen: the whole output
453+ // unless it has a size override (never in the overview).
454+ frame := sc.mon.Frame()
455+ var clip Rect
456+ if frame != (Rect{W: o.W, H: o.H}) {
457+ clip = frame
458+ }
459+ scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, 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), Layers: shownLayers(sc)}
453460 layout := sc.mon.Layout()
454461 var real map[WindowID]Placement
455462 if sc.mon.ov.open {
@@ -460,9 +467,9 @@ func (c *Core) publish(ctx context.Context) error {
460467 }
461468 }
462469 }
463- scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim)
470+ scene.Dim = floatDim(layout, frame, c.cfg.Floating.Dim)
464471 // Only the focused output lights the focused window's lines.
465- scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen)
472+ scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
466473 if sc.mon.ov.open {
467474 // Previews have no lines: the selected one is framed.
468475 scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2))
@@ -475,7 +482,7 @@ func (c *Core) publish(ctx context.Context) error {
475482 sw.Dim = c.cfg.Stash.Dim
476483 }
477484 scene.Windows = append(scene.Windows, sw)
478- t := configureTarget{output: sc.name(), area: o, focused: focused}
485+ t := configureTarget{output: sc.name(), area: frame, focused: focused}
479486 if !p.Hidden && p.Preview == 0 {
480487 // Only a sized configure needs the client size.
481488 t.client, t.imposed = c.clientRect(p), sc.mon.Current().imposedFloat(p.ID)
@@ -593,16 +600,24 @@ func (c *Core) updateInhibit(ctx context.Context) error {
593600 // and sends it to input when it changed. A hidden window holds nothing.
594601 func (c *Core) resolveConstraint() {
595602 var g ports.PointerConstraint
596- if r, ok := c.shownClientRect(c.constrained.ID); ok {
603+ if full, vis, ok := c.shownClient(c.constrained.ID); ok {
604+ r := full
597605 if w := c.constrained.Rect; w.W > 0 && w.H > 0 {
598- // The region is clipped to the window.
599- x0, y0 := max(r.X, r.X+w.X), max(r.Y, r.Y+w.Y)
600- x1, y1 := min(r.X+r.W, r.X+w.X+w.W), min(r.Y+r.H, r.Y+w.Y+w.H)
606+ // The region is window-local: placed from the full client origin,
607+ // clipped to the window, then to what the viewport shows.
608+ x0, y0 := max(full.X, full.X+w.X), max(full.Y, full.Y+w.Y)
609+ x1, y1 := min(full.X+full.W, full.X+w.X+w.W), min(full.Y+full.H, full.Y+w.Y+w.H)
601610 if x1 > x0 && y1 > y0 {
602611 r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
603612 }
604613 }
605- g = ports.PointerConstraint{Mode: c.constrained.Mode, Rect: r}
614+ r = intersect(r, vis)
615+ if r.W > 0 && r.H > 0 {
616+ g = ports.PointerConstraint{Mode: c.constrained.Mode, Rect: r}
617+ } else {
618+ // The region lies outside the viewport: hold the visible client.
619+ g = ports.PointerConstraint{Mode: c.constrained.Mode, Rect: vis}
620+ }
606621 }
607622 // The cursor follows core; input resyncs to it only on a change.
608623 if g.Mode == c.constraint.Mode && g.Rect == c.constraint.Rect {
@@ -617,26 +632,49 @@ func (c *Core) resolveConstraint() {
617632 }
618633 }
619634
620-// shownClientRect is the global logical client rectangle of a window
621-// drawn on its output; false when it is hidden or a preview.
622-func (c *Core) shownClientRect(id WindowID) (Rect, bool) {
635+// intersect is the overlap of a and b, empty (zero size) when disjoint.
636+func intersect(a, b Rect) Rect {
637+ x0, y0 := max(a.X, b.X), max(a.Y, b.Y)
638+ x1, y1 := min(a.X+a.W, b.X+b.W), min(a.Y+a.H, b.Y+b.H)
639+ if x1 <= x0 || y1 <= y0 {
640+ return Rect{}
641+ }
642+ return Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
643+}
644+
645+// shownClient is the global logical client rectangle of a window drawn on
646+// its output, whole (window-local coordinates start at its origin), and the
647+// part of it inside the workspace viewport, which alone takes the pointer.
648+// ok is false when it is hidden, a preview or entirely outside the viewport.
649+func (c *Core) shownClient(id WindowID) (full, visible Rect, ok bool) {
623650 if id == 0 {
624- return Rect{}, false
651+ return Rect{}, Rect{}, false
625652 }
626653 s, _ := c.screenOf(id)
627654 if s == nil {
628- return Rect{}, false
655+ return Rect{}, Rect{}, false
629656 }
630657 for _, p := range s.mon.Layout() {
631658 if p.ID != id || p.Hidden || p.Preview > 0 {
632659 continue
633660 }
634661 r := c.clientRect(p)
635- r.X += s.x
636- r.Y += s.y
637- return r, true
662+ full = Rect{X: r.X + s.x, Y: r.Y + s.y, W: r.W, H: r.H}
663+ f := s.mon.Frame()
664+ visible = intersect(r, f)
665+ visible.X, visible.Y = visible.X+s.x, visible.Y+s.y
666+ if visible.W <= 0 || visible.H <= 0 {
667+ return Rect{}, Rect{}, false
668+ }
669+ return full, visible, true
638670 }
639- return Rect{}, false
671+ return Rect{}, Rect{}, false
672+}
673+
674+// shownClientRect is the visible part of shownClient.
675+func (c *Core) shownClientRect(id WindowID) (Rect, bool) {
676+ _, vis, ok := c.shownClient(id)
677+ return vis, ok
640678 }
641679
642680 // warpPointer moves the cursor to a window-local point of the window under
@@ -646,11 +684,16 @@ func (c *Core) warpPointer(ctx context.Context, v ports.PointerWarp) error {
646684 if v.ID == 0 || v.ID != c.pointer || c.grab != 0 && c.grab != v.ID {
647685 return nil
648686 }
649- r, ok := c.shownClientRect(v.ID)
650- if !ok || v.X < 0 || v.Y < 0 || v.X >= float64(r.W) || v.Y >= float64(r.H) {
687+ full, vis, ok := c.shownClient(v.ID)
688+ if !ok || v.X < 0 || v.Y < 0 || v.X >= float64(full.W) || v.Y >= float64(full.H) {
689+ return nil
690+ }
691+ // Local to the whole client, and refused outside the viewport.
692+ gx, gy := float64(full.X)+v.X, float64(full.Y)+v.Y
693+ if gx < float64(vis.X) || gy < float64(vis.Y) || gx >= float64(vis.X+vis.W) || gy >= float64(vis.Y+vis.H) {
651694 return nil
652695 }
653- c.cursorX, c.cursorY = c.constraint.Clamp(float64(r.X)+v.X, float64(r.Y)+v.Y)
696+ c.cursorX, c.cursorY = c.constraint.Clamp(gx, gy)
654697 if c.ch.Constraints != nil {
655698 g := c.constraint
656699 g.X, g.Y, g.Warp = c.cursorX, c.cursorY, true
@@ -724,6 +767,9 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
724767 // picks a preview (overviewClick).
725768 return 0, 0, 0
726769 }
770+ if !sc.mon.frameHas(lx, ly) {
771+ return c.layerAt(sc, lx, ly, false)
772+ }
727773 var id WindowID
728774 var sx, sy float64
729775 for _, p := range sc.mon.Layout() {
@@ -231,11 +231,11 @@ func (c *Core) apply(cfg ports.Config) error {
231 if err != nil {231 if err != nil {
232 return err232 return err
233 }233 }
234- if ws.Name == "" || seen[ws.Name] || ws.MaxColumns < 0 {234+ if ws.Name == "" || seen[ws.Name] || ws.MaxColumns < 0 || ws.Size[0] < 0 || ws.Size[1] < 0 {
235 return fmt.Errorf("invalid workspace %q", ws.Name)235 return fmt.Errorf("invalid workspace %q", ws.Name)
236 }236 }
237 seen[ws.Name] = true237 seen[ws.Name] = true
238- named = append(named, NamedWorkspace{Name: ws.Name, Monitor: ws.Monitor, MaxColumns: ws.MaxColumns, Overflow: o})238+ named = append(named, NamedWorkspace{Name: ws.Name, Monitor: ws.Monitor, MaxColumns: ws.MaxColumns, Overflow: o, Size: ws.Size})
239 }239 }
240 specs, err := parseSlots(cfg.Workspaces)240 specs, err := parseSlots(cfg.Workspaces)
241 if err != nil {241 if err != nil {
@@ -335,7 +335,7 @@ func (c *Core) clientRect(p Placement) Rect {
335 // onScreen reports whether a placement is drawn on its output o: not335 // onScreen reports whether a placement is drawn on its output o: not
336 // hidden and not scrolled off.336 // hidden and not scrolled off.
337 func onScreen(p Placement, o Rect) bool {337 func onScreen(p Placement, o Rect) bool {
338- return !p.Hidden && p.Rect.Overlaps(Rect{W: o.W, H: o.H})338+ return !p.Hidden && p.Rect.Overlaps(o)
339 }339 }
340 340
341 // floatDim is the veil opacity of a layout: dim only when a float is341 // floatDim is the veil opacity of a layout: dim only when a float is
@@ -449,7 +449,14 @@ func (c *Core) publish(ctx context.Context) error {
449 for i, sc := range c.screens {449 for i, sc := range c.screens {
450 c.seq++450 c.seq++
451 o := sc.mon.Output()451 o := sc.mon.Output()
452- scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, 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), Layers: shownLayers(sc)}452+ // frame is the viewport of the workspace on screen: the whole output
453+ // unless it has a size override (never in the overview).
454+ frame := sc.mon.Frame()
455+ var clip Rect
456+ if frame != (Rect{W: o.W, H: o.H}) {
457+ clip = frame
458+ }
459+ scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, 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), Layers: shownLayers(sc)}
453 layout := sc.mon.Layout()460 layout := sc.mon.Layout()
454 var real map[WindowID]Placement461 var real map[WindowID]Placement
455 if sc.mon.ov.open {462 if sc.mon.ov.open {
@@ -460,9 +467,9 @@ func (c *Core) publish(ctx context.Context) error {
460 }467 }
461 }468 }
462 }469 }
463- scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim)470+ scene.Dim = floatDim(layout, frame, c.cfg.Floating.Dim)
464 // Only the focused output lights the focused window's lines.471 // Only the focused output lights the focused window's lines.
465- scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen)472+ scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
466 if sc.mon.ov.open {473 if sc.mon.ov.open {
467 // Previews have no lines: the selected one is framed.474 // Previews have no lines: the selected one is framed.
468 scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2))475 scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2))
@@ -475,7 +482,7 @@ func (c *Core) publish(ctx context.Context) error {
475 sw.Dim = c.cfg.Stash.Dim482 sw.Dim = c.cfg.Stash.Dim
476 }483 }
477 scene.Windows = append(scene.Windows, sw)484 scene.Windows = append(scene.Windows, sw)
478- t := configureTarget{output: sc.name(), area: o, focused: focused}485+ t := configureTarget{output: sc.name(), area: frame, focused: focused}
479 if !p.Hidden && p.Preview == 0 {486 if !p.Hidden && p.Preview == 0 {
480 // Only a sized configure needs the client size.487 // Only a sized configure needs the client size.
481 t.client, t.imposed = c.clientRect(p), sc.mon.Current().imposedFloat(p.ID)488 t.client, t.imposed = c.clientRect(p), sc.mon.Current().imposedFloat(p.ID)
@@ -593,16 +600,24 @@ func (c *Core) updateInhibit(ctx context.Context) error {
593 // and sends it to input when it changed. A hidden window holds nothing.600 // and sends it to input when it changed. A hidden window holds nothing.
594 func (c *Core) resolveConstraint() {601 func (c *Core) resolveConstraint() {
595 var g ports.PointerConstraint602 var g ports.PointerConstraint
596- if r, ok := c.shownClientRect(c.constrained.ID); ok {603+ if full, vis, ok := c.shownClient(c.constrained.ID); ok {
604+ r := full
597 if w := c.constrained.Rect; w.W > 0 && w.H > 0 {605 if w := c.constrained.Rect; w.W > 0 && w.H > 0 {
598- // The region is clipped to the window.606+ // The region is window-local: placed from the full client origin,
599- x0, y0 := max(r.X, r.X+w.X), max(r.Y, r.Y+w.Y)607+ // clipped to the window, then to what the viewport shows.
600- x1, y1 := min(r.X+r.W, r.X+w.X+w.W), min(r.Y+r.H, r.Y+w.Y+w.H)608+ x0, y0 := max(full.X, full.X+w.X), max(full.Y, full.Y+w.Y)
609+ x1, y1 := min(full.X+full.W, full.X+w.X+w.W), min(full.Y+full.H, full.Y+w.Y+w.H)
601 if x1 > x0 && y1 > y0 {610 if x1 > x0 && y1 > y0 {
602 r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}611 r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
603 }612 }
604 }613 }
605- g = ports.PointerConstraint{Mode: c.constrained.Mode, Rect: r}614+ r = intersect(r, vis)
615+ if r.W > 0 && r.H > 0 {
616+ g = ports.PointerConstraint{Mode: c.constrained.Mode, Rect: r}
617+ } else {
618+ // The region lies outside the viewport: hold the visible client.
619+ g = ports.PointerConstraint{Mode: c.constrained.Mode, Rect: vis}
620+ }
606 }621 }
607 // The cursor follows core; input resyncs to it only on a change.622 // The cursor follows core; input resyncs to it only on a change.
608 if g.Mode == c.constraint.Mode && g.Rect == c.constraint.Rect {623 if g.Mode == c.constraint.Mode && g.Rect == c.constraint.Rect {
@@ -617,26 +632,49 @@ func (c *Core) resolveConstraint() {
617 }632 }
618 }633 }
619 634
620-// shownClientRect is the global logical client rectangle of a window635+// intersect is the overlap of a and b, empty (zero size) when disjoint.
621-// drawn on its output; false when it is hidden or a preview.636+func intersect(a, b Rect) Rect {
622-func (c *Core) shownClientRect(id WindowID) (Rect, bool) {637+ x0, y0 := max(a.X, b.X), max(a.Y, b.Y)
638+ x1, y1 := min(a.X+a.W, b.X+b.W), min(a.Y+a.H, b.Y+b.H)
639+ if x1 <= x0 || y1 <= y0 {
640+ return Rect{}
641+ }
642+ return Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
643+}
644+
645+// shownClient is the global logical client rectangle of a window drawn on
646+// its output, whole (window-local coordinates start at its origin), and the
647+// part of it inside the workspace viewport, which alone takes the pointer.
648+// ok is false when it is hidden, a preview or entirely outside the viewport.
649+func (c *Core) shownClient(id WindowID) (full, visible Rect, ok bool) {
623 if id == 0 {650 if id == 0 {
624- return Rect{}, false651+ return Rect{}, Rect{}, false
625 }652 }
626 s, _ := c.screenOf(id)653 s, _ := c.screenOf(id)
627 if s == nil {654 if s == nil {
628- return Rect{}, false655+ return Rect{}, Rect{}, false
629 }656 }
630 for _, p := range s.mon.Layout() {657 for _, p := range s.mon.Layout() {
631 if p.ID != id || p.Hidden || p.Preview > 0 {658 if p.ID != id || p.Hidden || p.Preview > 0 {
632 continue659 continue
633 }660 }
634 r := c.clientRect(p)661 r := c.clientRect(p)
635- r.X += s.x662+ full = Rect{X: r.X + s.x, Y: r.Y + s.y, W: r.W, H: r.H}
636- r.Y += s.y663+ f := s.mon.Frame()
637- return r, true664+ visible = intersect(r, f)
665+ visible.X, visible.Y = visible.X+s.x, visible.Y+s.y
666+ if visible.W <= 0 || visible.H <= 0 {
667+ return Rect{}, Rect{}, false
668+ }
669+ return full, visible, true
638 }670 }
639- return Rect{}, false671+ return Rect{}, Rect{}, false
672+}
673+
674+// shownClientRect is the visible part of shownClient.
675+func (c *Core) shownClientRect(id WindowID) (Rect, bool) {
676+ _, vis, ok := c.shownClient(id)
677+ return vis, ok
640 }678 }
641 679
642 // warpPointer moves the cursor to a window-local point of the window under680 // warpPointer moves the cursor to a window-local point of the window under
@@ -646,11 +684,16 @@ func (c *Core) warpPointer(ctx context.Context, v ports.PointerWarp) error {
646 if v.ID == 0 || v.ID != c.pointer || c.grab != 0 && c.grab != v.ID {684 if v.ID == 0 || v.ID != c.pointer || c.grab != 0 && c.grab != v.ID {
647 return nil685 return nil
648 }686 }
649- r, ok := c.shownClientRect(v.ID)687+ full, vis, ok := c.shownClient(v.ID)
650- if !ok || v.X < 0 || v.Y < 0 || v.X >= float64(r.W) || v.Y >= float64(r.H) {688+ if !ok || v.X < 0 || v.Y < 0 || v.X >= float64(full.W) || v.Y >= float64(full.H) {
689+ return nil
690+ }
691+ // Local to the whole client, and refused outside the viewport.
692+ gx, gy := float64(full.X)+v.X, float64(full.Y)+v.Y
693+ if gx < float64(vis.X) || gy < float64(vis.Y) || gx >= float64(vis.X+vis.W) || gy >= float64(vis.Y+vis.H) {
651 return nil694 return nil
652 }695 }
653- c.cursorX, c.cursorY = c.constraint.Clamp(float64(r.X)+v.X, float64(r.Y)+v.Y)696+ c.cursorX, c.cursorY = c.constraint.Clamp(gx, gy)
654 if c.ch.Constraints != nil {697 if c.ch.Constraints != nil {
655 g := c.constraint698 g := c.constraint
656 g.X, g.Y, g.Warp = c.cursorX, c.cursorY, true699 g.X, g.Y, g.Warp = c.cursorX, c.cursorY, true
@@ -724,6 +767,9 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
724 // picks a preview (overviewClick).767 // picks a preview (overviewClick).
725 return 0, 0, 0768 return 0, 0, 0
726 }769 }
770+ if !sc.mon.frameHas(lx, ly) {
771+ return c.layerAt(sc, lx, ly, false)
772+ }
727 var id WindowID773 var id WindowID
728 var sx, sy float64774 var sx, sy float64
729 for _, p := range sc.mon.Layout() {775 for _, p := range sc.mon.Layout() {
M internal/core/layout.go +68 -29
@@ -137,12 +137,20 @@ type Workspace struct {
137137 ViewX int
138138 // shift slides the columns on screen past ViewX, in logical pixels,
139139 // while a swipe follows the fingers or its spring (motion) lands.
140- shift float64
141- motion *motion
142- Output Rect
143- Usable Rect
144- Gaps int
145- MaxColumns int
140+ shift float64
141+ motion *motion
142+ // Output is the effective viewport in monitor coordinates: the whole
143+ // monitor unless the workspace has a size override, then a centered
144+ // rectangle no larger than the monitor. Fullscreen fills it.
145+ Output Rect
146+ // Usable is the layer reservations of the monitor intersected with Output.
147+ Usable Rect
148+ // monitor is the full logical monitor and reserved its area left by
149+ // layer surfaces, both from the monitor; size is the override (0 inherits).
150+ monitor, reserved Rect
151+ size [2]int
152+ Gaps int
153+ MaxColumns int
146154 // border is drawn around floating windows, inside their rect.
147155 border int
148156 presets []Width
@@ -834,29 +842,54 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) {
834842 }
835843 }
836844 }
845+
846+// SetOutput sets the logical size of the monitor. The workspace viewport
847+// follows: the whole monitor, or the centered size override.
837848 func (w *Workspace) SetOutput(width, height int) {
838- if width < 0 {
839- width = 0
840- }
841- if height < 0 {
842- height = 0
843- }
844- old := w.Output
845- w.Output = Rect{W: width, H: height}
846- if w.Usable == old {
847- w.Usable = w.Output
849+ old := w.monitor
850+ w.monitor = Rect{W: max(width, 0), H: max(height, 0)}
851+ if w.reserved == old {
852+ w.reserved = w.monitor
848853 }
849- w.SetUsable(w.Usable)
854+ w.SetUsable(w.reserved)
850855 }
851856
852-// SetUsable clamps negative dimensions to zero and clips the usable rectangle
853-// to the output. Effective gaps shrink to fit the available area.
857+// SetUsable takes the monitor area left by layer surfaces, clamps negative
858+// dimensions to zero and clips it to the monitor. The usable rectangle is
859+// its intersection with the viewport. Effective gaps shrink to fit it.
854860 func (w *Workspace) SetUsable(r Rect) {
855- r.X = min(max(r.X, 0), w.Output.W)
856- r.Y = min(max(r.Y, 0), w.Output.H)
857- r.W = min(max(r.W, 0), w.Output.W-r.X)
858- r.H = min(max(r.H, 0), w.Output.H-r.Y)
859- w.Usable = r
861+ r.X = min(max(r.X, 0), w.monitor.W)
862+ r.Y = min(max(r.Y, 0), w.monitor.H)
863+ r.W = min(max(r.W, 0), w.monitor.W-r.X)
864+ r.H = min(max(r.H, 0), w.monitor.H-r.Y)
865+ w.reserved = r
866+ w.fit()
867+}
868+
869+// SetSize overrides the logical size of the workspace; a zero side inherits
870+// the monitor, one larger than it is clamped to it.
871+func (w *Workspace) SetSize(width, height int) {
872+ w.size = [2]int{max(width, 0), max(height, 0)}
873+ w.fit()
874+}
875+
876+// fit derives the viewport and usable area from the monitor, its reservations
877+// and the size override.
878+func (w *Workspace) fit() {
879+ side := func(want, have int) int {
880+ if want <= 0 {
881+ return have
882+ }
883+ return min(want, have)
884+ }
885+ vw, vh := side(w.size[0], w.monitor.W), side(w.size[1], w.monitor.H)
886+ w.Output = Rect{X: (w.monitor.W - vw) / 2, Y: (w.monitor.H - vh) / 2, W: vw, H: vh}
887+ r, o := w.reserved, w.Output
888+ x0, y0 := max(r.X, o.X), max(r.Y, o.Y)
889+ x1, y1 := min(r.X+r.W, o.X+o.W), min(r.Y+r.H, o.Y+o.H)
890+ u := Rect{X: min(max(x0, o.X), o.X+o.W), Y: min(max(y0, o.Y), o.Y+o.H)}
891+ u.W, u.H = max(x1-u.X, 0), max(y1-u.Y, 0)
892+ w.Usable = u
860893 w.reconcileFloats()
861894 w.scroll()
862895 }
@@ -872,6 +905,12 @@ func (w *Workspace) SetMaxColumns(n int) {
872905 w.MaxColumns = n
873906 w.scroll()
874907 }
908+
909+// overviewArea is the monitor area the overview lays rows out in: the whole
910+// area left by layer surfaces, whatever the workspace's size. Tiles keep the
911+// workspace's own geometry, scaled uniformly.
912+func (w *Workspace) overviewArea() Rect { return w.reserved }
913+
875914 func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
876915
877916 // pinned reports whether focus moves inside the workspace are off: the
@@ -973,7 +1012,7 @@ func (w *Workspace) scroll() {
9731012 left := w.columnX(w.Focus) - w.ViewX
9741013 width := w.columnWidth(w.Focus)
9751014 if w.fullscreenColumn(w.Focus) {
976- w.ViewX += left
1015+ w.ViewX += left - w.Output.X
9771016 return
9781017 }
9791018 minX := w.Usable.X + w.gap()
@@ -1122,9 +1161,9 @@ func (w *Workspace) Layout() []Placement {
11221161 hidden := (cover != 0 && id != cover) || ((fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full) || maximized
11231162 if full {
11241163 // Scroll mode aligns the view on the column; fixed never scrolls.
1125- r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H}
1126- if w.Overflow == OverflowFixed {
1127- r.X = 0
1164+ r = w.Output
1165+ if w.Overflow != OverflowFixed {
1166+ r.X = col.X
11281167 }
11291168 }
11301169 if hidden {
@@ -1167,7 +1206,7 @@ func (w *Workspace) Layout() []Placement {
11671206 continue
11681207 }
11691208 if w.fullscreen == f.ID {
1170- p.Rect, p.Fullscreen, p.Inset = Rect{W: w.Output.W, H: w.Output.H}, true, 0
1209+ p.Rect, p.Fullscreen, p.Inset = w.Output, true, 0
11711210 }
11721211 result = append(result, p)
11731212 }
@@ -137,12 +137,20 @@ type Workspace struct {
137 ViewX int137 ViewX int
138 // shift slides the columns on screen past ViewX, in logical pixels,138 // shift slides the columns on screen past ViewX, in logical pixels,
139 // while a swipe follows the fingers or its spring (motion) lands.139 // while a swipe follows the fingers or its spring (motion) lands.
140- shift float64140+ shift float64
141- motion *motion141+ motion *motion
142- Output Rect142+ // Output is the effective viewport in monitor coordinates: the whole
143- Usable Rect143+ // monitor unless the workspace has a size override, then a centered
144- Gaps int144+ // rectangle no larger than the monitor. Fullscreen fills it.
145- MaxColumns int145+ Output Rect
146+ // Usable is the layer reservations of the monitor intersected with Output.
147+ Usable Rect
148+ // monitor is the full logical monitor and reserved its area left by
149+ // layer surfaces, both from the monitor; size is the override (0 inherits).
150+ monitor, reserved Rect
151+ size [2]int
152+ Gaps int
153+ MaxColumns int
146 // border is drawn around floating windows, inside their rect.154 // border is drawn around floating windows, inside their rect.
147 border int155 border int
148 presets []Width156 presets []Width
@@ -834,29 +842,54 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) {
834 }842 }
835 }843 }
836 }844 }
845+
846+// SetOutput sets the logical size of the monitor. The workspace viewport
847+// follows: the whole monitor, or the centered size override.
837 func (w *Workspace) SetOutput(width, height int) {848 func (w *Workspace) SetOutput(width, height int) {
838- if width < 0 {849+ old := w.monitor
839- width = 0850+ w.monitor = Rect{W: max(width, 0), H: max(height, 0)}
840- }851+ if w.reserved == old {
841- if height < 0 {852+ w.reserved = w.monitor
842- height = 0
843- }
844- old := w.Output
845- w.Output = Rect{W: width, H: height}
846- if w.Usable == old {
847- w.Usable = w.Output
848 }853 }
849- w.SetUsable(w.Usable)854+ w.SetUsable(w.reserved)
850 }855 }
851 856
852-// SetUsable clamps negative dimensions to zero and clips the usable rectangle857+// SetUsable takes the monitor area left by layer surfaces, clamps negative
853-// to the output. Effective gaps shrink to fit the available area.858+// dimensions to zero and clips it to the monitor. The usable rectangle is
859+// its intersection with the viewport. Effective gaps shrink to fit it.
854 func (w *Workspace) SetUsable(r Rect) {860 func (w *Workspace) SetUsable(r Rect) {
855- r.X = min(max(r.X, 0), w.Output.W)861+ r.X = min(max(r.X, 0), w.monitor.W)
856- r.Y = min(max(r.Y, 0), w.Output.H)862+ r.Y = min(max(r.Y, 0), w.monitor.H)
857- r.W = min(max(r.W, 0), w.Output.W-r.X)863+ r.W = min(max(r.W, 0), w.monitor.W-r.X)
858- r.H = min(max(r.H, 0), w.Output.H-r.Y)864+ r.H = min(max(r.H, 0), w.monitor.H-r.Y)
859- w.Usable = r865+ w.reserved = r
866+ w.fit()
867+}
868+
869+// SetSize overrides the logical size of the workspace; a zero side inherits
870+// the monitor, one larger than it is clamped to it.
871+func (w *Workspace) SetSize(width, height int) {
872+ w.size = [2]int{max(width, 0), max(height, 0)}
873+ w.fit()
874+}
875+
876+// fit derives the viewport and usable area from the monitor, its reservations
877+// and the size override.
878+func (w *Workspace) fit() {
879+ side := func(want, have int) int {
880+ if want <= 0 {
881+ return have
882+ }
883+ return min(want, have)
884+ }
885+ vw, vh := side(w.size[0], w.monitor.W), side(w.size[1], w.monitor.H)
886+ w.Output = Rect{X: (w.monitor.W - vw) / 2, Y: (w.monitor.H - vh) / 2, W: vw, H: vh}
887+ r, o := w.reserved, w.Output
888+ x0, y0 := max(r.X, o.X), max(r.Y, o.Y)
889+ x1, y1 := min(r.X+r.W, o.X+o.W), min(r.Y+r.H, o.Y+o.H)
890+ u := Rect{X: min(max(x0, o.X), o.X+o.W), Y: min(max(y0, o.Y), o.Y+o.H)}
891+ u.W, u.H = max(x1-u.X, 0), max(y1-u.Y, 0)
892+ w.Usable = u
860 w.reconcileFloats()893 w.reconcileFloats()
861 w.scroll()894 w.scroll()
862 }895 }
@@ -872,6 +905,12 @@ func (w *Workspace) SetMaxColumns(n int) {
872 w.MaxColumns = n905 w.MaxColumns = n
873 w.scroll()906 w.scroll()
874 }907 }
908+
909+// overviewArea is the monitor area the overview lays rows out in: the whole
910+// area left by layer surfaces, whatever the workspace's size. Tiles keep the
911+// workspace's own geometry, scaled uniformly.
912+func (w *Workspace) overviewArea() Rect { return w.reserved }
913+
875 func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }914 func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
876 915
877 // pinned reports whether focus moves inside the workspace are off: the916 // pinned reports whether focus moves inside the workspace are off: the
@@ -973,7 +1012,7 @@ func (w *Workspace) scroll() {
973 left := w.columnX(w.Focus) - w.ViewX1012 left := w.columnX(w.Focus) - w.ViewX
974 width := w.columnWidth(w.Focus)1013 width := w.columnWidth(w.Focus)
975 if w.fullscreenColumn(w.Focus) {1014 if w.fullscreenColumn(w.Focus) {
976- w.ViewX += left1015+ w.ViewX += left - w.Output.X
977 return1016 return
978 }1017 }
979 minX := w.Usable.X + w.gap()1018 minX := w.Usable.X + w.gap()
@@ -1122,9 +1161,9 @@ func (w *Workspace) Layout() []Placement {
1122 hidden := (cover != 0 && id != cover) || ((fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full) || maximized1161 hidden := (cover != 0 && id != cover) || ((fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full) || maximized
1123 if full {1162 if full {
1124 // Scroll mode aligns the view on the column; fixed never scrolls.1163 // Scroll mode aligns the view on the column; fixed never scrolls.
1125- r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H}1164+ r = w.Output
1126- if w.Overflow == OverflowFixed {1165+ if w.Overflow != OverflowFixed {
1127- r.X = 01166+ r.X = col.X
1128 }1167 }
1129 }1168 }
1130 if hidden {1169 if hidden {
@@ -1167,7 +1206,7 @@ func (w *Workspace) Layout() []Placement {
1167 continue1206 continue
1168 }1207 }
1169 if w.fullscreen == f.ID {1208 if w.fullscreen == f.ID {
1170- p.Rect, p.Fullscreen, p.Inset = Rect{W: w.Output.W, H: w.Output.H}, true, 01209+ p.Rect, p.Fullscreen, p.Inset = w.Output, true, 0
1171 }1210 }
1172 result = append(result, p)1211 result = append(result, p)
1173 }1212 }
M internal/core/monitor.go +41 -0
@@ -49,6 +49,9 @@ type NamedWorkspace struct {
4949 Monitor string
5050 MaxColumns int
5151 Overflow Overflow
52+ // Size overrides the logical width and height; a zero side inherits
53+ // the monitor and the scale is always the monitor's.
54+ Size [2]int
5255 }
5356
5457 func NewMonitor() *Monitor { return newMonitor("", "") }
@@ -362,6 +365,7 @@ func (m *Monitor) ownWorkspace(w *Workspace, id WindowID) bool {
362365 func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
363366 fs := m.newWorkspace()
364367 fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns
368+ fs.SetSize(w.size[0], w.size[1])
365369 fs.origin = w
366370 fs.back.id = id
367371 if f := w.floatIndex(id); f >= 0 {
@@ -675,8 +679,25 @@ func (m *Monitor) Layout() []Placement {
675679 return m.slideLayout(result)
676680 }
677681
682+// Output is the whole logical monitor, whatever the workspaces' sizes.
678683 func (m *Monitor) Output() Rect { return m.template.Output }
679684
685+// Frame is where the workspace on screen draws and takes input, in monitor
686+// coordinates: its viewport, or the whole monitor in the overview. Slides
687+// involving a sized workspace remain settled inside the current frame.
688+func (m *Monitor) Frame() Rect {
689+ if m.ov.open {
690+ return m.Output()
691+ }
692+ return m.Current().Output
693+}
694+
695+// frameHas reports whether the output-local point is inside the frame.
696+func (m *Monitor) frameHas(x, y float64) bool {
697+ f := m.Frame()
698+ return x >= float64(f.X) && x < float64(f.X+f.W) && y >= float64(f.Y) && y < float64(f.Y+f.H)
699+}
700+
680701 func (m *Monitor) each(f func(*Workspace)) {
681702 f(&m.template)
682703 for _, w := range m.Workspaces {
@@ -737,6 +758,16 @@ func (m *Monitor) applyNamed() {
737758 if s.MaxColumns > 0 {
738759 w.SetMaxColumns(s.MaxColumns)
739760 }
761+ // Named workspaces take their configured size, a fullscreen sibling
762+ // that of its named origin. Any other workspace keeps its own: a
763+ // sibling detached from its origin must not resize on reload.
764+ switch {
765+ case w.Name != "":
766+ w.SetSize(s.Size[0], s.Size[1])
767+ case w.origin != nil && w.origin.Name != "":
768+ size := specs[w.origin.Name].Size
769+ w.SetSize(size[0], size[1])
770+ }
740771 })
741772 }
742773
@@ -748,10 +779,20 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) {
748779 for _, s := range specs {
749780 want[s.Name] = s
750781 }
782+ // A sibling inherits its named origin's override only while that
783+ // declaration exists. Removing it restores monitor-sized geometry.
784+ for _, w := range m.Workspaces {
785+ if w.origin != nil && w.origin.Name != "" {
786+ if _, ok := want[w.origin.Name]; !ok {
787+ w.SetSize(0, 0)
788+ }
789+ }
790+ }
751791 byName := map[string]*Workspace{}
752792 for _, w := range m.Workspaces {
753793 if _, ok := want[w.Name]; w.Name != "" && !ok {
754794 w.Name = ""
795+ w.SetSize(0, 0)
755796 }
756797 if w.Name != "" {
757798 byName[w.Name] = w
@@ -49,6 +49,9 @@ type NamedWorkspace struct {
49 Monitor string49 Monitor string
50 MaxColumns int50 MaxColumns int
51 Overflow Overflow51 Overflow Overflow
52+ // Size overrides the logical width and height; a zero side inherits
53+ // the monitor and the scale is always the monitor's.
54+ Size [2]int
52 }55 }
53 56
54 func NewMonitor() *Monitor { return newMonitor("", "") }57 func NewMonitor() *Monitor { return newMonitor("", "") }
@@ -362,6 +365,7 @@ func (m *Monitor) ownWorkspace(w *Workspace, id WindowID) bool {
362 func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {365 func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
363 fs := m.newWorkspace()366 fs := m.newWorkspace()
364 fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns367 fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns
368+ fs.SetSize(w.size[0], w.size[1])
365 fs.origin = w369 fs.origin = w
366 fs.back.id = id370 fs.back.id = id
367 if f := w.floatIndex(id); f >= 0 {371 if f := w.floatIndex(id); f >= 0 {
@@ -675,8 +679,25 @@ func (m *Monitor) Layout() []Placement {
675 return m.slideLayout(result)679 return m.slideLayout(result)
676 }680 }
677 681
682+// Output is the whole logical monitor, whatever the workspaces' sizes.
678 func (m *Monitor) Output() Rect { return m.template.Output }683 func (m *Monitor) Output() Rect { return m.template.Output }
679 684
685+// Frame is where the workspace on screen draws and takes input, in monitor
686+// coordinates: its viewport, or the whole monitor in the overview. Slides
687+// involving a sized workspace remain settled inside the current frame.
688+func (m *Monitor) Frame() Rect {
689+ if m.ov.open {
690+ return m.Output()
691+ }
692+ return m.Current().Output
693+}
694+
695+// frameHas reports whether the output-local point is inside the frame.
696+func (m *Monitor) frameHas(x, y float64) bool {
697+ f := m.Frame()
698+ return x >= float64(f.X) && x < float64(f.X+f.W) && y >= float64(f.Y) && y < float64(f.Y+f.H)
699+}
700+
680 func (m *Monitor) each(f func(*Workspace)) {701 func (m *Monitor) each(f func(*Workspace)) {
681 f(&m.template)702 f(&m.template)
682 for _, w := range m.Workspaces {703 for _, w := range m.Workspaces {
@@ -737,6 +758,16 @@ func (m *Monitor) applyNamed() {
737 if s.MaxColumns > 0 {758 if s.MaxColumns > 0 {
738 w.SetMaxColumns(s.MaxColumns)759 w.SetMaxColumns(s.MaxColumns)
739 }760 }
761+ // Named workspaces take their configured size, a fullscreen sibling
762+ // that of its named origin. Any other workspace keeps its own: a
763+ // sibling detached from its origin must not resize on reload.
764+ switch {
765+ case w.Name != "":
766+ w.SetSize(s.Size[0], s.Size[1])
767+ case w.origin != nil && w.origin.Name != "":
768+ size := specs[w.origin.Name].Size
769+ w.SetSize(size[0], size[1])
770+ }
740 })771 })
741 }772 }
742 773
@@ -748,10 +779,20 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) {
748 for _, s := range specs {779 for _, s := range specs {
749 want[s.Name] = s780 want[s.Name] = s
750 }781 }
782+ // A sibling inherits its named origin's override only while that
783+ // declaration exists. Removing it restores monitor-sized geometry.
784+ for _, w := range m.Workspaces {
785+ if w.origin != nil && w.origin.Name != "" {
786+ if _, ok := want[w.origin.Name]; !ok {
787+ w.SetSize(0, 0)
788+ }
789+ }
790+ }
751 byName := map[string]*Workspace{}791 byName := map[string]*Workspace{}
752 for _, w := range m.Workspaces {792 for _, w := range m.Workspaces {
753 if _, ok := want[w.Name]; w.Name != "" && !ok {793 if _, ok := want[w.Name]; w.Name != "" && !ok {
754 w.Name = ""794 w.Name = ""
795+ w.SetSize(0, 0)
755 }796 }
756 if w.Name != "" {797 if w.Name != "" {
757 byName[w.Name] = w798 byName[w.Name] = w
M internal/core/overview.go +9 -7
@@ -360,7 +360,7 @@ func (m *Monitor) OverviewPick(id WindowID) {
360360 // with its stash pile and covering-float stack. Other workspaces are hidden.
361361 func (m *Monitor) overviewLayout() []Placement {
362362 cur := m.Current()
363- u := cur.Usable
363+ u := cur.overviewArea()
364364 gap := u.H * 3 / 100
365365 var result []Placement
366366 hc := cur.rowHeight()
@@ -483,12 +483,14 @@ func (w *Workspace) pileWidth() int {
483483 }
484484 card := int(math.Round(float64(w.stashRect().W) * overviewCardZoom))
485485 // Never more than a third of the width: the columns keep the rest.
486- return min(card+(min(len(w.Stash), overviewCards)-1)*w.cardStep()+w.Usable.W*2/100, w.Usable.W/3)
486+ a := w.overviewArea()
487+ return min(card+(min(len(w.Stash), overviewCards)-1)*w.cardStep()+a.W*2/100, a.W/3)
487488 }
488489
489490 // cardStep is the offset between stacked cards, smaller on tiny outputs.
490491 func (w *Workspace) cardStep() int {
491- return min(overviewCardStep, w.Usable.W/100, w.Usable.H/50)
492+ a := w.overviewArea()
493+ return min(overviewCardStep, a.W/100, a.H/50)
492494 }
493495
494496 // pile places the stash of w as cards on the left of the row whose top is
@@ -506,10 +508,10 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement {
506508 }
507509 front = min(max(front, 0), n-1)
508510 r := w.stashRect()
509- u := w.Usable
511+ u := w.overviewArea()
510512 cw := int(math.Round(float64(r.W) * overviewCardZoom))
511513 ch := int(math.Round(float64(r.H) * overviewCardZoom))
512- rowH := int(math.Round(float64(u.H) * w.overviewZoom()))
514+ rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))
513515 // Stack rows reserve two peek steps above the front card. Align the
514516 // stash with that front band, not with the top of its envelope.
515517 x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2
@@ -530,7 +532,7 @@ func (w *Workspace) overviewZoom() float64 {
530532 if span <= 0 {
531533 return overviewMaxZoom
532534 }
533- room := float64(w.Usable.W-w.pileWidth()) * 0.96
535+ room := float64(w.overviewArea().W-w.pileWidth()) * 0.96
534536 z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom)
535537 if len(w.stack()) > 1 {
536538 // Reserve two clear peeks on either side of the front row.
@@ -624,7 +626,7 @@ func (w *Workspace) previewRow(y int, dim, lit bool) []Placement {
624626 }
625627
626628 func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement {
627- u := w.Usable
629+ u := w.overviewArea()
628630 z := w.overviewZoom()
629631 scale := func(v int) int { return int(math.Round(float64(v) * z)) }
630632 width := scale(span)
@@ -360,7 +360,7 @@ func (m *Monitor) OverviewPick(id WindowID) {
360 // with its stash pile and covering-float stack. Other workspaces are hidden.360 // with its stash pile and covering-float stack. Other workspaces are hidden.
361 func (m *Monitor) overviewLayout() []Placement {361 func (m *Monitor) overviewLayout() []Placement {
362 cur := m.Current()362 cur := m.Current()
363- u := cur.Usable363+ u := cur.overviewArea()
364 gap := u.H * 3 / 100364 gap := u.H * 3 / 100
365 var result []Placement365 var result []Placement
366 hc := cur.rowHeight()366 hc := cur.rowHeight()
@@ -483,12 +483,14 @@ func (w *Workspace) pileWidth() int {
483 }483 }
484 card := int(math.Round(float64(w.stashRect().W) * overviewCardZoom))484 card := int(math.Round(float64(w.stashRect().W) * overviewCardZoom))
485 // Never more than a third of the width: the columns keep the rest.485 // Never more than a third of the width: the columns keep the rest.
486- return min(card+(min(len(w.Stash), overviewCards)-1)*w.cardStep()+w.Usable.W*2/100, w.Usable.W/3)486+ a := w.overviewArea()
487+ return min(card+(min(len(w.Stash), overviewCards)-1)*w.cardStep()+a.W*2/100, a.W/3)
487 }488 }
488 489
489 // cardStep is the offset between stacked cards, smaller on tiny outputs.490 // cardStep is the offset between stacked cards, smaller on tiny outputs.
490 func (w *Workspace) cardStep() int {491 func (w *Workspace) cardStep() int {
491- return min(overviewCardStep, w.Usable.W/100, w.Usable.H/50)492+ a := w.overviewArea()
493+ return min(overviewCardStep, a.W/100, a.H/50)
492 }494 }
493 495
494 // pile places the stash of w as cards on the left of the row whose top is496 // pile places the stash of w as cards on the left of the row whose top is
@@ -506,10 +508,10 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement {
506 }508 }
507 front = min(max(front, 0), n-1)509 front = min(max(front, 0), n-1)
508 r := w.stashRect()510 r := w.stashRect()
509- u := w.Usable511+ u := w.overviewArea()
510 cw := int(math.Round(float64(r.W) * overviewCardZoom))512 cw := int(math.Round(float64(r.W) * overviewCardZoom))
511 ch := int(math.Round(float64(r.H) * overviewCardZoom))513 ch := int(math.Round(float64(r.H) * overviewCardZoom))
512- rowH := int(math.Round(float64(u.H) * w.overviewZoom()))514+ rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))
513 // Stack rows reserve two peek steps above the front card. Align the515 // Stack rows reserve two peek steps above the front card. Align the
514 // stash with that front band, not with the top of its envelope.516 // stash with that front band, not with the top of its envelope.
515 x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2517 x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2
@@ -530,7 +532,7 @@ func (w *Workspace) overviewZoom() float64 {
530 if span <= 0 {532 if span <= 0 {
531 return overviewMaxZoom533 return overviewMaxZoom
532 }534 }
533- room := float64(w.Usable.W-w.pileWidth()) * 0.96535+ room := float64(w.overviewArea().W-w.pileWidth()) * 0.96
534 z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom)536 z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom)
535 if len(w.stack()) > 1 {537 if len(w.stack()) > 1 {
536 // Reserve two clear peeks on either side of the front row.538 // Reserve two clear peeks on either side of the front row.
@@ -624,7 +626,7 @@ func (w *Workspace) previewRow(y int, dim, lit bool) []Placement {
624 }626 }
625 627
626 func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement {628 func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement {
627- u := w.Usable629+ u := w.overviewArea()
628 z := w.overviewZoom()630 z := w.overviewZoom()
629 scale := func(v int) int { return int(math.Round(float64(v) * z)) }631 scale := func(v int) int { return int(math.Round(float64(v) * z)) }
630 width := scale(span)632 width := scale(span)
M internal/core/popup.go +8 -1
@@ -148,7 +148,7 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) {
148148 if pl.ID == id {
149149 // Hidden or scrolled off: not on screen, like its popups.
150150 // A preview takes no input and shows no popups.
151- if !onScreen(pl, sc.mon.Output()) || pl.Preview > 0 {
151+ if !onScreen(pl, sc.mon.Frame()) || pl.Preview > 0 {
152152 return nil, Rect{}, false
153153 }
154154 return sc, c.clientRect(pl), true
@@ -180,7 +180,11 @@ func (c *Core) placePopup(ctx context.Context, v ports.PopupRequest) error {
180180 }
181181 return c.command(ctx, ports.ClosePopup{ID: v.ID})
182182 }
183+ // Window popups stay in the workspace viewport, layer popups in the output.
183184 o := sc.mon.Output()
185+ if !c.onLayer(c.popupRoot(v.Parent)) {
186+ o = sc.mon.Frame()
187+ }
184188 bounds := Rect{X: o.X - pr.X, Y: o.Y - pr.Y, W: o.W, H: o.H}
185189 p := c.popups[v.ID]
186190 if p == nil {
@@ -308,6 +312,9 @@ func (c *Core) popupAt(sc *screen, lx, ly float64, overLayers bool) (WindowID, f
308312 if !ok || s != sc {
309313 continue
310314 }
315+ if !overLayers && !sc.mon.frameHas(lx, ly) {
316+ continue
317+ }
311318 if lx >= float64(r.X) && lx < float64(r.X+r.W) && ly >= float64(r.Y) && ly < float64(r.Y+r.H) && c.acceptsInput(p.id, lx-float64(r.X), ly-float64(r.Y)) {
312319 return p.id, lx - float64(r.X), ly - float64(r.Y)
313320 }
@@ -148,7 +148,7 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) {
148 if pl.ID == id {148 if pl.ID == id {
149 // Hidden or scrolled off: not on screen, like its popups.149 // Hidden or scrolled off: not on screen, like its popups.
150 // A preview takes no input and shows no popups.150 // A preview takes no input and shows no popups.
151- if !onScreen(pl, sc.mon.Output()) || pl.Preview > 0 {151+ if !onScreen(pl, sc.mon.Frame()) || pl.Preview > 0 {
152 return nil, Rect{}, false152 return nil, Rect{}, false
153 }153 }
154 return sc, c.clientRect(pl), true154 return sc, c.clientRect(pl), true
@@ -180,7 +180,11 @@ func (c *Core) placePopup(ctx context.Context, v ports.PopupRequest) error {
180 }180 }
181 return c.command(ctx, ports.ClosePopup{ID: v.ID})181 return c.command(ctx, ports.ClosePopup{ID: v.ID})
182 }182 }
183+ // Window popups stay in the workspace viewport, layer popups in the output.
183 o := sc.mon.Output()184 o := sc.mon.Output()
185+ if !c.onLayer(c.popupRoot(v.Parent)) {
186+ o = sc.mon.Frame()
187+ }
184 bounds := Rect{X: o.X - pr.X, Y: o.Y - pr.Y, W: o.W, H: o.H}188 bounds := Rect{X: o.X - pr.X, Y: o.Y - pr.Y, W: o.W, H: o.H}
185 p := c.popups[v.ID]189 p := c.popups[v.ID]
186 if p == nil {190 if p == nil {
@@ -308,6 +312,9 @@ func (c *Core) popupAt(sc *screen, lx, ly float64, overLayers bool) (WindowID, f
308 if !ok || s != sc {312 if !ok || s != sc {
309 continue313 continue
310 }314 }
315+ if !overLayers && !sc.mon.frameHas(lx, ly) {
316+ continue
317+ }
311 if lx >= float64(r.X) && lx < float64(r.X+r.W) && ly >= float64(r.Y) && ly < float64(r.Y+r.H) && c.acceptsInput(p.id, lx-float64(r.X), ly-float64(r.Y)) {318 if lx >= float64(r.X) && lx < float64(r.X+r.W) && ly >= float64(r.Y) && ly < float64(r.Y+r.H) && c.acceptsInput(p.id, lx-float64(r.X), ly-float64(r.Y)) {
312 return p.id, lx - float64(r.X), ly - float64(r.Y)319 return p.id, lx - float64(r.X), ly - float64(r.Y)
313 }320 }
M internal/core/slide.go +25 -1
@@ -85,12 +85,36 @@ func (w *Workspace) snap(target float64, forward bool) (view, focus int) {
8585
8686 func (m *Monitor) stopSwitch() { m.switchOff, m.switchMotion, m.switchList = 0, nil, nil }
8787
88+// framedSwitch keeps transitions involving a sized workspace settled. A
89+// monitor-wide animation has no per-workspace crop; it must not expose
90+// columns outside their viewport while sliding between different frames.
91+func (m *Monitor) framedSwitch() bool {
92+ if m.Current().Output != m.Output() {
93+ return true
94+ }
95+ list := m.Workspaces
96+ if m.switchList != nil {
97+ list = m.switchList
98+ }
99+ base := indexOf(list, m.Current())
100+ if base < 0 {
101+ return false
102+ }
103+ pos := float64(base) + m.switchOff
104+ for _, i := range [2]int{int(math.Floor(pos)), int(math.Ceil(pos))} {
105+ if i >= 0 && i < len(list) && m.has(list[i]) && list[i].Output != m.Output() {
106+ return true
107+ }
108+ }
109+ return false
110+}
111+
88112 // slideLayout adds the workspaces a workspace slide shows next to the
89113 // current one, moved by their distance, to the current layout (moved too).
90114 // A landing slide places them as the list was when the swipe began
91115 // (switchList): an empty workspace dropped since shows as background.
92116 func (m *Monitor) slideLayout(cur []Placement) []Placement {
93- if m.switchOff == 0 || m.shown != nil {
117+ if m.switchOff == 0 || m.shown != nil || m.framedSwitch() {
94118 return cur
95119 }
96120 list := m.Workspaces
@@ -85,12 +85,36 @@ func (w *Workspace) snap(target float64, forward bool) (view, focus int) {
85 85
86 func (m *Monitor) stopSwitch() { m.switchOff, m.switchMotion, m.switchList = 0, nil, nil }86 func (m *Monitor) stopSwitch() { m.switchOff, m.switchMotion, m.switchList = 0, nil, nil }
87 87
88+// framedSwitch keeps transitions involving a sized workspace settled. A
89+// monitor-wide animation has no per-workspace crop; it must not expose
90+// columns outside their viewport while sliding between different frames.
91+func (m *Monitor) framedSwitch() bool {
92+ if m.Current().Output != m.Output() {
93+ return true
94+ }
95+ list := m.Workspaces
96+ if m.switchList != nil {
97+ list = m.switchList
98+ }
99+ base := indexOf(list, m.Current())
100+ if base < 0 {
101+ return false
102+ }
103+ pos := float64(base) + m.switchOff
104+ for _, i := range [2]int{int(math.Floor(pos)), int(math.Ceil(pos))} {
105+ if i >= 0 && i < len(list) && m.has(list[i]) && list[i].Output != m.Output() {
106+ return true
107+ }
108+ }
109+ return false
110+}
111+
88 // slideLayout adds the workspaces a workspace slide shows next to the112 // slideLayout adds the workspaces a workspace slide shows next to the
89 // current one, moved by their distance, to the current layout (moved too).113 // current one, moved by their distance, to the current layout (moved too).
90 // A landing slide places them as the list was when the swipe began114 // A landing slide places them as the list was when the swipe began
91 // (switchList): an empty workspace dropped since shows as background.115 // (switchList): an empty workspace dropped since shows as background.
92 func (m *Monitor) slideLayout(cur []Placement) []Placement {116 func (m *Monitor) slideLayout(cur []Placement) []Placement {
93- if m.switchOff == 0 || m.shown != nil {117+ if m.switchOff == 0 || m.shown != nil || m.framedSwitch() {
94 return cur118 return cur
95 }119 }
96 list := m.Workspaces120 list := m.Workspaces
M internal/core/stash.go +1 -1
@@ -172,7 +172,7 @@ func (w *Workspace) stashLayout(focusedID, cover WindowID) []Placement {
172172 p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll}
173173 switch {
174174 case f.ID == cover:
175- p.Rect, p.Fullscreen, p.Inset = Rect{W: w.Output.W, H: w.Output.H}, true, 0
175+ p.Rect, p.Fullscreen, p.Inset = w.Output, true, 0
176176 case w.stashHidden || cover != 0:
177177 p.Hidden = true
178178 case i == w.stashAt:
@@ -172,7 +172,7 @@ func (w *Workspace) stashLayout(focusedID, cover WindowID) []Placement {
172 p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll}172 p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll}
173 switch {173 switch {
174 case f.ID == cover:174 case f.ID == cover:
175- p.Rect, p.Fullscreen, p.Inset = Rect{W: w.Output.W, H: w.Output.H}, true, 0175+ p.Rect, p.Fullscreen, p.Inset = w.Output, true, 0
176 case w.stashHidden || cover != 0:176 case w.stashHidden || cover != 0:
177 p.Hidden = true177 p.Hidden = true
178 case i == w.stashAt:178 case i == w.stashAt:
M internal/core/state.go +1 -1
@@ -39,7 +39,7 @@ func (c *Core) state() ports.State {
3939 // Visible follows the configures: drawn on the output.
4040 shown := map[WindowID]bool{}
4141 for _, p := range m.Layout() {
42- shown[p.ID] = onScreen(p, m.Output())
42+ shown[p.ID] = onScreen(p, m.Frame())
4343 }
4444 for _, w := range m.all() {
4545 n := indexOf(m.Workspaces, w) + 1
@@ -39,7 +39,7 @@ func (c *Core) state() ports.State {
39 // Visible follows the configures: drawn on the output.39 // Visible follows the configures: drawn on the output.
40 shown := map[WindowID]bool{}40 shown := map[WindowID]bool{}
41 for _, p := range m.Layout() {41 for _, p := range m.Layout() {
42- shown[p.ID] = onScreen(p, m.Output())42+ shown[p.ID] = onScreen(p, m.Frame())
43 }43 }
44 for _, w := range m.all() {44 for _, w := range m.all() {
45 n := indexOf(m.Workspaces, w) + 145 n := indexOf(m.Workspaces, w) + 1
A internal/core/workspace_size_internal_test.go +166 -0
@@ -0,0 +1,166 @@
1+package core
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "math"
6+ "testing"
7+)
8+
9+func sizedMonitor(overflow Overflow) *Monitor {
10+ m := newMonitor("", "")
11+ m.SetOutput(300, 200)
12+ m.SetMaxColumns(2)
13+ m.SetOverflow(overflow)
14+ m.SetNamed([]NamedWorkspace{{Name: "s", Size: [2]int{100, 100}}})
15+ m.ToggleNamed("s")
16+ return m
17+}
18+
19+// Sized transitions remain settled inside their frame rather than exposing
20+// scrolling columns in the margins. Overview retains the whole monitor.
21+func TestSizedTransitionsStaySettledAndOverviewSpansMonitor(t *testing.T) {
22+ m := sizedMonitor(OverflowScroll)
23+ if got, want := m.Frame(), (Rect{X: 100, Y: 50, W: 100, H: 100}); got != want {
24+ t.Fatalf("frame %+v want %+v", got, want)
25+ }
26+ w := m.Current()
27+ m.hidden = nil
28+ m.Workspaces = append(m.Workspaces, w)
29+ m.Active = len(m.Workspaces) - 1
30+ m.shown = nil
31+ m.switchOff = -0.4
32+ if got := m.Frame(); got != m.Current().Output || !m.framedSwitch() {
33+ t.Fatalf("sliding frame %+v", got)
34+ }
35+ m.AddWindow(1)
36+ before := m.Current().Layout()
37+ during := m.slideLayout(before)
38+ if len(during) != len(before) || during[0].Rect != before[0].Rect {
39+ t.Fatalf("sized workspace animated outside frame: %+v", during)
40+ }
41+ m.switchOff = 0
42+ if got := m.Frame(); got != m.Current().Output {
43+ t.Fatalf("settled frame %+v", got)
44+ }
45+ m.ToggleOverview()
46+ if got := m.Frame(); got != m.Output() {
47+ t.Fatalf("overview frame %+v", got)
48+ }
49+}
50+
51+// A sized neighbor must not disable animation between inherited workspaces.
52+func TestSizedNeighborDisablesSlideButInheritedNeighborDoesNot(t *testing.T) {
53+ m := newMonitor("", "")
54+ m.SetOutput(300, 200)
55+ m.AddWindow(1)
56+ m.Workspaces[1].SetSize(100, 100)
57+ before := m.Layout()
58+ m.switchOff = 0.4
59+ if !m.framedSwitch() || m.Layout()[0].Rect != before[0].Rect {
60+ t.Fatal("slide toward sized neighbor escaped viewport")
61+ }
62+ m.Workspaces[1].SetSize(0, 0)
63+ if m.framedSwitch() || m.Layout()[0].Rect == before[0].Rect {
64+ t.Fatal("normal inherited workspace stopped animating")
65+ }
66+}
67+
68+// A fullscreen sibling detached from its origin keeps its size on reload;
69+// one still tied to a named origin follows the config.
70+func TestDetachedFullscreenSiblingKeepsSizeOnReload(t *testing.T) {
71+ m := sizedMonitor(OverflowFixed)
72+ m.AddWindow(1)
73+ m.AddWindow(2)
74+ m.ToggleFullscreen()
75+ fs := m.Current()
76+ if fs.origin == nil || fs.size != [2]int{100, 100} {
77+ t.Fatalf("sibling %+v size %v", fs.origin, fs.size)
78+ }
79+ m.SetNamed([]NamedWorkspace{{Name: "s", Size: [2]int{120, 80}}})
80+ if fs.size != [2]int{120, 80} {
81+ t.Fatalf("attached sibling did not follow config: %v", fs.size)
82+ }
83+ fs.origin, fs.back = nil, origPlace{} // moved away or home gone
84+ m.SetNamed([]NamedWorkspace{{Name: "s", Size: [2]int{50, 50}}})
85+ if fs.size != [2]int{120, 80} {
86+ t.Fatalf("detached sibling resized: %v", fs.size)
87+ }
88+ // A plain unnamed workspace is never resized by config.
89+ if u := m.Workspaces[0]; u.size != ([2]int{}) {
90+ t.Fatalf("unnamed size %v", u.size)
91+ }
92+}
93+
94+// Removing a named origin restores inherited geometry on its sibling.
95+func TestFullscreenSiblingDropsRemovedNamedSize(t *testing.T) {
96+ m := sizedMonitor(OverflowFixed)
97+ m.AddWindow(1)
98+ m.AddWindow(2)
99+ m.ToggleFullscreen()
100+ fs := m.Current()
101+ if fs.Output == m.Output() || fs.origin == nil {
102+ t.Fatal("test did not create sized fullscreen sibling")
103+ }
104+ m.SetNamed(nil)
105+ if fs.Output != m.Output() || fs.size != [2]int{} {
106+ t.Fatalf("removed declaration retained size: %+v", fs.Output)
107+ }
108+}
109+
110+// The overview keeps monitor-wide placement and real client aspect ratios.
111+func TestOverviewIgnoresWorkspaceSize(t *testing.T) {
112+ m := sizedMonitor(OverflowScroll)
113+ for _, mm := range []*Monitor{m} {
114+ mm.AddWindow(1)
115+ mm.AddWindow(2)
116+ }
117+ real := previewOf(t, m.Layout(), 1).Rect
118+ m.ToggleOverview()
119+ p := previewOf(t, m.Layout(), 1)
120+ if p.Preview <= 0 || p.Rect.W <= 0 {
121+ t.Fatalf("%+v", p)
122+ }
123+ if math.Abs(float64(p.Rect.W)/float64(p.Rect.H)-float64(real.W)/float64(real.H)) > 0.05 {
124+ t.Fatalf("aspect: preview %+v real %+v", p.Rect, real)
125+ }
126+ if math.Abs(float64(p.Rect.W)-float64(real.W)*p.Preview) > 1 {
127+ t.Fatalf("width %d, zoom %v of %d", p.Rect.W, p.Preview, real.W)
128+ }
129+ // Row centered in the 300 wide monitor, not in the 100 wide viewport.
130+ l, r := previewOf(t, m.Layout(), 1).Rect, previewOf(t, m.Layout(), 2).Rect
131+ if mid := (l.X + r.X + r.W) / 2; mid < 145 || mid > 155 {
132+ t.Fatalf("row center %d: %+v %+v", mid, l, r)
133+ }
134+ // Vertically centered in the monitor as well, whatever the viewport.
135+ if mid := l.Y + l.H/2; mid < 95 || mid > 105 {
136+ t.Fatalf("row middle %d: %+v", mid, l)
137+ }
138+}
139+
140+// The pointer constraint and warp bounds are the part of a client inside the
141+// viewport, offset by the screen; a client wholly outside has none.
142+func TestShownClientRectIsClippedToFrame(t *testing.T) {
143+ var cfg ports.Config
144+ cfg.Keyboard.CmdKey = "super"
145+ cfg.Layout.MaxColumns = 2
146+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Layouts: make(chan ports.Layout, 1), Constraints: make(chan ports.PointerConstraint, 1), State: make(chan ports.State, 1), Workspaces: make(chan ports.Workspaces, 1)})
147+ if err != nil {
148+ t.Fatal(err)
149+ }
150+ c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
151+ s := c.screens[0]
152+ s.x = 1000
153+ s.mon.SetNamed([]NamedWorkspace{{Name: "s", Size: [2]int{100, 100}}})
154+ s.mon.ToggleNamed("s")
155+ for id := WindowID(1); id <= 3; id++ {
156+ s.mon.AddWindow(id)
157+ }
158+ // Frame is {100,50,100,100}; columns are 50 wide, the focused last one
159+ // ends at the frame's right edge and the first is scrolled off the left.
160+ if r, ok := c.shownClientRect(3); !ok || r != (Rect{X: 1150, Y: 50, W: 50, H: 100}) {
161+ t.Fatalf("visible %+v %v", r, ok)
162+ }
163+ if r, ok := c.shownClientRect(1); ok {
164+ t.Fatalf("scrolled off window has bounds %+v", r)
165+ }
166+}
@@ -0,0 +1,166 @@
1+package core
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "math"
6+ "testing"
7+)
8+
9+func sizedMonitor(overflow Overflow) *Monitor {
10+ m := newMonitor("", "")
11+ m.SetOutput(300, 200)
12+ m.SetMaxColumns(2)
13+ m.SetOverflow(overflow)
14+ m.SetNamed([]NamedWorkspace{{Name: "s", Size: [2]int{100, 100}}})
15+ m.ToggleNamed("s")
16+ return m
17+}
18+
19+// Sized transitions remain settled inside their frame rather than exposing
20+// scrolling columns in the margins. Overview retains the whole monitor.
21+func TestSizedTransitionsStaySettledAndOverviewSpansMonitor(t *testing.T) {
22+ m := sizedMonitor(OverflowScroll)
23+ if got, want := m.Frame(), (Rect{X: 100, Y: 50, W: 100, H: 100}); got != want {
24+ t.Fatalf("frame %+v want %+v", got, want)
25+ }
26+ w := m.Current()
27+ m.hidden = nil
28+ m.Workspaces = append(m.Workspaces, w)
29+ m.Active = len(m.Workspaces) - 1
30+ m.shown = nil
31+ m.switchOff = -0.4
32+ if got := m.Frame(); got != m.Current().Output || !m.framedSwitch() {
33+ t.Fatalf("sliding frame %+v", got)
34+ }
35+ m.AddWindow(1)
36+ before := m.Current().Layout()
37+ during := m.slideLayout(before)
38+ if len(during) != len(before) || during[0].Rect != before[0].Rect {
39+ t.Fatalf("sized workspace animated outside frame: %+v", during)
40+ }
41+ m.switchOff = 0
42+ if got := m.Frame(); got != m.Current().Output {
43+ t.Fatalf("settled frame %+v", got)
44+ }
45+ m.ToggleOverview()
46+ if got := m.Frame(); got != m.Output() {
47+ t.Fatalf("overview frame %+v", got)
48+ }
49+}
50+
51+// A sized neighbor must not disable animation between inherited workspaces.
52+func TestSizedNeighborDisablesSlideButInheritedNeighborDoesNot(t *testing.T) {
53+ m := newMonitor("", "")
54+ m.SetOutput(300, 200)
55+ m.AddWindow(1)
56+ m.Workspaces[1].SetSize(100, 100)
57+ before := m.Layout()
58+ m.switchOff = 0.4
59+ if !m.framedSwitch() || m.Layout()[0].Rect != before[0].Rect {
60+ t.Fatal("slide toward sized neighbor escaped viewport")
61+ }
62+ m.Workspaces[1].SetSize(0, 0)
63+ if m.framedSwitch() || m.Layout()[0].Rect == before[0].Rect {
64+ t.Fatal("normal inherited workspace stopped animating")
65+ }
66+}
67+
68+// A fullscreen sibling detached from its origin keeps its size on reload;
69+// one still tied to a named origin follows the config.
70+func TestDetachedFullscreenSiblingKeepsSizeOnReload(t *testing.T) {
71+ m := sizedMonitor(OverflowFixed)
72+ m.AddWindow(1)
73+ m.AddWindow(2)
74+ m.ToggleFullscreen()
75+ fs := m.Current()
76+ if fs.origin == nil || fs.size != [2]int{100, 100} {
77+ t.Fatalf("sibling %+v size %v", fs.origin, fs.size)
78+ }
79+ m.SetNamed([]NamedWorkspace{{Name: "s", Size: [2]int{120, 80}}})
80+ if fs.size != [2]int{120, 80} {
81+ t.Fatalf("attached sibling did not follow config: %v", fs.size)
82+ }
83+ fs.origin, fs.back = nil, origPlace{} // moved away or home gone
84+ m.SetNamed([]NamedWorkspace{{Name: "s", Size: [2]int{50, 50}}})
85+ if fs.size != [2]int{120, 80} {
86+ t.Fatalf("detached sibling resized: %v", fs.size)
87+ }
88+ // A plain unnamed workspace is never resized by config.
89+ if u := m.Workspaces[0]; u.size != ([2]int{}) {
90+ t.Fatalf("unnamed size %v", u.size)
91+ }
92+}
93+
94+// Removing a named origin restores inherited geometry on its sibling.
95+func TestFullscreenSiblingDropsRemovedNamedSize(t *testing.T) {
96+ m := sizedMonitor(OverflowFixed)
97+ m.AddWindow(1)
98+ m.AddWindow(2)
99+ m.ToggleFullscreen()
100+ fs := m.Current()
101+ if fs.Output == m.Output() || fs.origin == nil {
102+ t.Fatal("test did not create sized fullscreen sibling")
103+ }
104+ m.SetNamed(nil)
105+ if fs.Output != m.Output() || fs.size != [2]int{} {
106+ t.Fatalf("removed declaration retained size: %+v", fs.Output)
107+ }
108+}
109+
110+// The overview keeps monitor-wide placement and real client aspect ratios.
111+func TestOverviewIgnoresWorkspaceSize(t *testing.T) {
112+ m := sizedMonitor(OverflowScroll)
113+ for _, mm := range []*Monitor{m} {
114+ mm.AddWindow(1)
115+ mm.AddWindow(2)
116+ }
117+ real := previewOf(t, m.Layout(), 1).Rect
118+ m.ToggleOverview()
119+ p := previewOf(t, m.Layout(), 1)
120+ if p.Preview <= 0 || p.Rect.W <= 0 {
121+ t.Fatalf("%+v", p)
122+ }
123+ if math.Abs(float64(p.Rect.W)/float64(p.Rect.H)-float64(real.W)/float64(real.H)) > 0.05 {
124+ t.Fatalf("aspect: preview %+v real %+v", p.Rect, real)
125+ }
126+ if math.Abs(float64(p.Rect.W)-float64(real.W)*p.Preview) > 1 {
127+ t.Fatalf("width %d, zoom %v of %d", p.Rect.W, p.Preview, real.W)
128+ }
129+ // Row centered in the 300 wide monitor, not in the 100 wide viewport.
130+ l, r := previewOf(t, m.Layout(), 1).Rect, previewOf(t, m.Layout(), 2).Rect
131+ if mid := (l.X + r.X + r.W) / 2; mid < 145 || mid > 155 {
132+ t.Fatalf("row center %d: %+v %+v", mid, l, r)
133+ }
134+ // Vertically centered in the monitor as well, whatever the viewport.
135+ if mid := l.Y + l.H/2; mid < 95 || mid > 105 {
136+ t.Fatalf("row middle %d: %+v", mid, l)
137+ }
138+}
139+
140+// The pointer constraint and warp bounds are the part of a client inside the
141+// viewport, offset by the screen; a client wholly outside has none.
142+func TestShownClientRectIsClippedToFrame(t *testing.T) {
143+ var cfg ports.Config
144+ cfg.Keyboard.CmdKey = "super"
145+ cfg.Layout.MaxColumns = 2
146+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Layouts: make(chan ports.Layout, 1), Constraints: make(chan ports.PointerConstraint, 1), State: make(chan ports.State, 1), Workspaces: make(chan ports.Workspaces, 1)})
147+ if err != nil {
148+ t.Fatal(err)
149+ }
150+ c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
151+ s := c.screens[0]
152+ s.x = 1000
153+ s.mon.SetNamed([]NamedWorkspace{{Name: "s", Size: [2]int{100, 100}}})
154+ s.mon.ToggleNamed("s")
155+ for id := WindowID(1); id <= 3; id++ {
156+ s.mon.AddWindow(id)
157+ }
158+ // Frame is {100,50,100,100}; columns are 50 wide, the focused last one
159+ // ends at the frame's right edge and the first is scrolled off the left.
160+ if r, ok := c.shownClientRect(3); !ok || r != (Rect{X: 1150, Y: 50, W: 50, H: 100}) {
161+ t.Fatalf("visible %+v %v", r, ok)
162+ }
163+ if r, ok := c.shownClientRect(1); ok {
164+ t.Fatalf("scrolled off window has bounds %+v", r)
165+ }
166+}
A internal/core/workspace_size_test.go +229 -0
@@ -0,0 +1,229 @@
1+package core_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+var ultrawide = ports.OutputInfo{Name: "DP-3", Make: "Acme", Model: "W", Serial: "3", Width: 3440, Height: 1440}
10+
11+// sizedRig runs an ultrawide with the named workspace "focus" (1920x1080)
12+// bound to Alt+w, and shows it.
13+func sizedRig(t *testing.T, overflow string, edit func(*ports.Config)) (*multiRig, ports.Scene) {
14+ t.Helper()
15+ r := startMulti(t, func(c *ports.Config) {
16+ c.Layout.Gaps = 0
17+ c.Layout.MaxColumns = 2
18+ c.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1920, 1080}, LayoutRules: ports.LayoutRules{Overflow: overflow}}}
19+ c.Binds["Alt+w"] = "workspace focus"
20+ if edit != nil {
21+ edit(c)
22+ }
23+ }, ultrawide)
24+ return r, r.key(t, "w", ports.ModAlt)[0]
25+}
26+
27+var frame = ports.Rect{X: 760, Y: 180, W: 1920, H: 1080}
28+
29+func within(r, o ports.Rect) bool {
30+ return r.X >= o.X && r.Y >= o.Y && r.X+r.W <= o.X+o.W && r.Y+r.H <= o.Y+o.H
31+}
32+
33+func TestWorkspaceSizeInheritsByDefault(t *testing.T) {
34+ r := startMulti(t, func(c *ports.Config) { c.Layout.Gaps = 0 }, ultrawide)
35+ set := r.mapWindow(t, 1)
36+ if set[0].WorkspaceClip != (ports.Rect{}) || set[0].Windows[0].Rect != (ports.Rect{W: 3440, H: 1440}) {
37+ t.Fatalf("%+v", set[0])
38+ }
39+}
40+
41+func TestWorkspaceSizeScrollColumnsStayInFrame(t *testing.T) {
42+ r, _ := sizedRig(t, "scroll", nil)
43+ r.mapWindow(t, 1)
44+ r.mapWindow(t, 2)
45+ s := r.mapWindow(t, 3)[0]
46+ if s.WorkspaceClip != frame || s.OutputWidth != 3440 || s.OutputHeight != 1440 {
47+ t.Fatalf("clip %+v output %dx%d", s.WorkspaceClip, s.OutputWidth, s.OutputHeight)
48+ }
49+ for _, w := range s.Windows {
50+ if w.Hidden || !w.Rect.Overlaps(frame) {
51+ continue
52+ }
53+ if w.Rect.Y != frame.Y || w.Rect.H != frame.H {
54+ t.Fatalf("%+v not framed", w)
55+ }
56+ }
57+ // Focused column (last) ends at the frame's right edge, half the frame wide.
58+ last := s.Windows[2].Rect
59+ if last != (ports.Rect{X: 1720, Y: 180, W: 960, H: 1080}) {
60+ t.Fatal(last)
61+ }
62+}
63+
64+func TestWorkspaceSizeFixedColumnsFillFrame(t *testing.T) {
65+ r, _ := sizedRig(t, "fixed", nil)
66+ r.mapWindow(t, 1)
67+ s := r.mapWindow(t, 2)[0]
68+ for i, want := range []ports.Rect{{X: 760, Y: 180, W: 960, H: 1080}, {X: 1720, Y: 180, W: 960, H: 1080}} {
69+ if s.Windows[i].Rect != want {
70+ t.Fatalf("%d: %+v", i, s.Windows[i])
71+ }
72+ }
73+}
74+
75+func TestWorkspaceSizeFullscreenFillsFrame(t *testing.T) {
76+ r, _ := sizedRig(t, "scroll", nil)
77+ r.mapWindow(t, 1)
78+ r.mapWindow(t, 2)
79+ s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]
80+ full := 0
81+ for _, w := range s.Windows {
82+ if w.Fullscreen {
83+ full++
84+ if w.Rect != frame {
85+ t.Fatalf("%+v", w)
86+ }
87+ }
88+ }
89+ if full != 1 {
90+ t.Fatal(s.Windows)
91+ }
92+}
93+
94+func TestWorkspaceSizeFixedFullscreenSiblingKeepsFrame(t *testing.T) {
95+ r, _ := sizedRig(t, "fixed", nil)
96+ r.mapWindow(t, 1)
97+ r.mapWindow(t, 2)
98+ s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]
99+ if s.WorkspaceClip != frame {
100+ t.Fatalf("sibling clip %+v", s.WorkspaceClip)
101+ }
102+ for _, w := range s.Windows {
103+ if w.Fullscreen && w.Rect != frame {
104+ t.Fatalf("%+v", w)
105+ }
106+ }
107+ // Reload keeps the origin's size on the sibling; removing it frees both.
108+ cfg := r.cfg
109+ r.reload <- ports.ConfigChanged{Config: cfg}
110+ if s := receive(t, r.scenes)[0]; s.WorkspaceClip != frame {
111+ t.Fatalf("after reload %+v", s.WorkspaceClip)
112+ }
113+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", LayoutRules: ports.LayoutRules{Overflow: "fixed"}}}
114+ r.reload <- ports.ConfigChanged{Config: cfg}
115+ if s := receive(t, r.scenes)[0]; s.WorkspaceClip != (ports.Rect{}) {
116+ t.Fatalf("after size removal %+v", s.WorkspaceClip)
117+ }
118+}
119+
120+func TestWorkspaceSizeFloatsContained(t *testing.T) {
121+ r, _ := sizedRig(t, "scroll", nil)
122+ r.mapWindow(t, 1)
123+ r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 5000, Height: 4000}
124+ s := receive(t, r.scenes)[0]
125+ for _, w := range s.Windows {
126+ if w.Floating && !within(w.Rect, frame) {
127+ t.Fatalf("float %+v outside %+v", w.Rect, frame)
128+ }
129+ }
130+}
131+
132+func TestWorkspaceSizeReloadAndRemoval(t *testing.T) {
133+ r, _ := sizedRig(t, "scroll", nil)
134+ r.mapWindow(t, 1)
135+ cfg := r.cfg
136+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1000, 600}}}
137+ r.reload <- ports.ConfigChanged{Config: cfg}
138+ s := receive(t, r.scenes)[0]
139+ if want := (ports.Rect{X: 1220, Y: 420, W: 1000, H: 600}); s.WorkspaceClip != want || s.Windows[0].Rect != want {
140+ t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0])
141+ }
142+ // Larger than the monitor clamps to it: no clip, no invisible area.
143+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{9000, 9000}}}
144+ r.reload <- ports.ConfigChanged{Config: cfg}
145+ s = receive(t, r.scenes)[0]
146+ if s.WorkspaceClip != (ports.Rect{}) || s.Windows[0].Rect != (ports.Rect{W: 3440, H: 1440}) {
147+ t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0])
148+ }
149+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1920, 1080}}}
150+ r.reload <- ports.ConfigChanged{Config: cfg}
151+ if s = receive(t, r.scenes)[0]; s.WorkspaceClip != frame {
152+ t.Fatal(s.WorkspaceClip)
153+ }
154+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus"}}
155+ r.reload <- ports.ConfigChanged{Config: cfg}
156+ if s = receive(t, r.scenes)[0]; s.WorkspaceClip != (ports.Rect{}) || s.Windows[0].Rect != (ports.Rect{W: 3440, H: 1440}) {
157+ t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0])
158+ }
159+}
160+
161+func TestWorkspaceSizeFollowsOutputResize(t *testing.T) {
162+ r, _ := sizedRig(t, "scroll", nil)
163+ r.mapWindow(t, 1)
164+ smaller := ultrawide
165+ smaller.Width, smaller.Height = 1600, 900
166+ s := r.plug(t, smaller)[0]
167+ // 1920x1080 no longer fits: it clamps to the monitor.
168+ if s.WorkspaceClip != (ports.Rect{}) || s.Windows[0].Rect != (ports.Rect{W: 1600, H: 900}) {
169+ t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0])
170+ }
171+ s = r.plug(t, ultrawide)[0]
172+ if s.WorkspaceClip != frame || s.Windows[0].Rect != frame {
173+ t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0])
174+ }
175+}
176+
177+func TestWorkspaceSizeReservedZoneIntersectsFrame(t *testing.T) {
178+ r, _ := sizedRig(t, "scroll", nil)
179+ // A bar inside the top of the monitor but above the frame reserves nothing of it.
180+ bar := ports.LayerSurface{ID: 9, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 100, ExclusiveZone: 100}
181+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
182+ receive(t, r.scenes)
183+ s := r.mapWindow(t, 1)[0]
184+ if s.Windows[0].Rect != frame {
185+ t.Fatalf("%+v", s.Windows[0])
186+ }
187+ // A bar reaching into the frame shrinks it from the top.
188+ bar.Height, bar.ExclusiveZone = 300, 300
189+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
190+ s = receive(t, r.scenes)[0]
191+ if want := (ports.Rect{X: 760, Y: 300, W: 1920, H: 960}); s.Windows[0].Rect != want || s.WorkspaceClip != frame {
192+ t.Fatalf("%+v clip %+v", s.Windows[0], s.WorkspaceClip)
193+ }
194+}
195+
196+func TestWorkspaceSizeFractionalScaleUnchanged(t *testing.T) {
197+ r, _ := sizedRig(t, "scroll", func(c *ports.Config) {
198+ c.Outputs = []ports.OutputConfig{{Name: "DP-3", Scale: 2}}
199+ })
200+ s := r.mapWindow(t, 1)[0]
201+ // The monitor is 1720x720 logical; a 1920x1080 workspace clamps to it.
202+ if s.OutputWidth != 1720 || s.OutputHeight != 720 || s.Scale != 2 || s.WorkspaceClip != (ports.Rect{}) {
203+ t.Fatalf("%dx%d scale %v clip %+v", s.OutputWidth, s.OutputHeight, s.Scale, s.WorkspaceClip)
204+ }
205+}
206+
207+func TestWorkspaceSizeInputOutsideFrame(t *testing.T) {
208+ r, _ := sizedRig(t, "scroll", nil)
209+ r.mapWindow(t, 1)
210+ r.mapWindow(t, 2)
211+ for len(r.commands) > 0 {
212+ <-r.commands
213+ }
214+ // Inside the frame the window under the pointer gets the focus; window 1
215+ // is scrolled partly off but its visible part outside the frame is dead.
216+ r.input <- ports.PointerMotion{X: 2000, Y: 700}
217+ if v, ok := command(t, r.commands).(ports.PointerFocus); !ok || v.ID != 2 {
218+ t.Fatal(v)
219+ }
220+ r.input <- ports.PointerMotion{X: 100, Y: 700}
221+ for {
222+ if v, ok := command(t, r.commands).(ports.PointerFocus); ok {
223+ if v.ID != 0 {
224+ t.Fatalf("outside the frame: %+v", v)
225+ }
226+ break
227+ }
228+ }
229+}
@@ -0,0 +1,229 @@
1+package core_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+var ultrawide = ports.OutputInfo{Name: "DP-3", Make: "Acme", Model: "W", Serial: "3", Width: 3440, Height: 1440}
10+
11+// sizedRig runs an ultrawide with the named workspace "focus" (1920x1080)
12+// bound to Alt+w, and shows it.
13+func sizedRig(t *testing.T, overflow string, edit func(*ports.Config)) (*multiRig, ports.Scene) {
14+ t.Helper()
15+ r := startMulti(t, func(c *ports.Config) {
16+ c.Layout.Gaps = 0
17+ c.Layout.MaxColumns = 2
18+ c.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1920, 1080}, LayoutRules: ports.LayoutRules{Overflow: overflow}}}
19+ c.Binds["Alt+w"] = "workspace focus"
20+ if edit != nil {
21+ edit(c)
22+ }
23+ }, ultrawide)
24+ return r, r.key(t, "w", ports.ModAlt)[0]
25+}
26+
27+var frame = ports.Rect{X: 760, Y: 180, W: 1920, H: 1080}
28+
29+func within(r, o ports.Rect) bool {
30+ return r.X >= o.X && r.Y >= o.Y && r.X+r.W <= o.X+o.W && r.Y+r.H <= o.Y+o.H
31+}
32+
33+func TestWorkspaceSizeInheritsByDefault(t *testing.T) {
34+ r := startMulti(t, func(c *ports.Config) { c.Layout.Gaps = 0 }, ultrawide)
35+ set := r.mapWindow(t, 1)
36+ if set[0].WorkspaceClip != (ports.Rect{}) || set[0].Windows[0].Rect != (ports.Rect{W: 3440, H: 1440}) {
37+ t.Fatalf("%+v", set[0])
38+ }
39+}
40+
41+func TestWorkspaceSizeScrollColumnsStayInFrame(t *testing.T) {
42+ r, _ := sizedRig(t, "scroll", nil)
43+ r.mapWindow(t, 1)
44+ r.mapWindow(t, 2)
45+ s := r.mapWindow(t, 3)[0]
46+ if s.WorkspaceClip != frame || s.OutputWidth != 3440 || s.OutputHeight != 1440 {
47+ t.Fatalf("clip %+v output %dx%d", s.WorkspaceClip, s.OutputWidth, s.OutputHeight)
48+ }
49+ for _, w := range s.Windows {
50+ if w.Hidden || !w.Rect.Overlaps(frame) {
51+ continue
52+ }
53+ if w.Rect.Y != frame.Y || w.Rect.H != frame.H {
54+ t.Fatalf("%+v not framed", w)
55+ }
56+ }
57+ // Focused column (last) ends at the frame's right edge, half the frame wide.
58+ last := s.Windows[2].Rect
59+ if last != (ports.Rect{X: 1720, Y: 180, W: 960, H: 1080}) {
60+ t.Fatal(last)
61+ }
62+}
63+
64+func TestWorkspaceSizeFixedColumnsFillFrame(t *testing.T) {
65+ r, _ := sizedRig(t, "fixed", nil)
66+ r.mapWindow(t, 1)
67+ s := r.mapWindow(t, 2)[0]
68+ for i, want := range []ports.Rect{{X: 760, Y: 180, W: 960, H: 1080}, {X: 1720, Y: 180, W: 960, H: 1080}} {
69+ if s.Windows[i].Rect != want {
70+ t.Fatalf("%d: %+v", i, s.Windows[i])
71+ }
72+ }
73+}
74+
75+func TestWorkspaceSizeFullscreenFillsFrame(t *testing.T) {
76+ r, _ := sizedRig(t, "scroll", nil)
77+ r.mapWindow(t, 1)
78+ r.mapWindow(t, 2)
79+ s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]
80+ full := 0
81+ for _, w := range s.Windows {
82+ if w.Fullscreen {
83+ full++
84+ if w.Rect != frame {
85+ t.Fatalf("%+v", w)
86+ }
87+ }
88+ }
89+ if full != 1 {
90+ t.Fatal(s.Windows)
91+ }
92+}
93+
94+func TestWorkspaceSizeFixedFullscreenSiblingKeepsFrame(t *testing.T) {
95+ r, _ := sizedRig(t, "fixed", nil)
96+ r.mapWindow(t, 1)
97+ r.mapWindow(t, 2)
98+ s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]
99+ if s.WorkspaceClip != frame {
100+ t.Fatalf("sibling clip %+v", s.WorkspaceClip)
101+ }
102+ for _, w := range s.Windows {
103+ if w.Fullscreen && w.Rect != frame {
104+ t.Fatalf("%+v", w)
105+ }
106+ }
107+ // Reload keeps the origin's size on the sibling; removing it frees both.
108+ cfg := r.cfg
109+ r.reload <- ports.ConfigChanged{Config: cfg}
110+ if s := receive(t, r.scenes)[0]; s.WorkspaceClip != frame {
111+ t.Fatalf("after reload %+v", s.WorkspaceClip)
112+ }
113+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", LayoutRules: ports.LayoutRules{Overflow: "fixed"}}}
114+ r.reload <- ports.ConfigChanged{Config: cfg}
115+ if s := receive(t, r.scenes)[0]; s.WorkspaceClip != (ports.Rect{}) {
116+ t.Fatalf("after size removal %+v", s.WorkspaceClip)
117+ }
118+}
119+
120+func TestWorkspaceSizeFloatsContained(t *testing.T) {
121+ r, _ := sizedRig(t, "scroll", nil)
122+ r.mapWindow(t, 1)
123+ r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 5000, Height: 4000}
124+ s := receive(t, r.scenes)[0]
125+ for _, w := range s.Windows {
126+ if w.Floating && !within(w.Rect, frame) {
127+ t.Fatalf("float %+v outside %+v", w.Rect, frame)
128+ }
129+ }
130+}
131+
132+func TestWorkspaceSizeReloadAndRemoval(t *testing.T) {
133+ r, _ := sizedRig(t, "scroll", nil)
134+ r.mapWindow(t, 1)
135+ cfg := r.cfg
136+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1000, 600}}}
137+ r.reload <- ports.ConfigChanged{Config: cfg}
138+ s := receive(t, r.scenes)[0]
139+ if want := (ports.Rect{X: 1220, Y: 420, W: 1000, H: 600}); s.WorkspaceClip != want || s.Windows[0].Rect != want {
140+ t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0])
141+ }
142+ // Larger than the monitor clamps to it: no clip, no invisible area.
143+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{9000, 9000}}}
144+ r.reload <- ports.ConfigChanged{Config: cfg}
145+ s = receive(t, r.scenes)[0]
146+ if s.WorkspaceClip != (ports.Rect{}) || s.Windows[0].Rect != (ports.Rect{W: 3440, H: 1440}) {
147+ t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0])
148+ }
149+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1920, 1080}}}
150+ r.reload <- ports.ConfigChanged{Config: cfg}
151+ if s = receive(t, r.scenes)[0]; s.WorkspaceClip != frame {
152+ t.Fatal(s.WorkspaceClip)
153+ }
154+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus"}}
155+ r.reload <- ports.ConfigChanged{Config: cfg}
156+ if s = receive(t, r.scenes)[0]; s.WorkspaceClip != (ports.Rect{}) || s.Windows[0].Rect != (ports.Rect{W: 3440, H: 1440}) {
157+ t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0])
158+ }
159+}
160+
161+func TestWorkspaceSizeFollowsOutputResize(t *testing.T) {
162+ r, _ := sizedRig(t, "scroll", nil)
163+ r.mapWindow(t, 1)
164+ smaller := ultrawide
165+ smaller.Width, smaller.Height = 1600, 900
166+ s := r.plug(t, smaller)[0]
167+ // 1920x1080 no longer fits: it clamps to the monitor.
168+ if s.WorkspaceClip != (ports.Rect{}) || s.Windows[0].Rect != (ports.Rect{W: 1600, H: 900}) {
169+ t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0])
170+ }
171+ s = r.plug(t, ultrawide)[0]
172+ if s.WorkspaceClip != frame || s.Windows[0].Rect != frame {
173+ t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0])
174+ }
175+}
176+
177+func TestWorkspaceSizeReservedZoneIntersectsFrame(t *testing.T) {
178+ r, _ := sizedRig(t, "scroll", nil)
179+ // A bar inside the top of the monitor but above the frame reserves nothing of it.
180+ bar := ports.LayerSurface{ID: 9, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 100, ExclusiveZone: 100}
181+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
182+ receive(t, r.scenes)
183+ s := r.mapWindow(t, 1)[0]
184+ if s.Windows[0].Rect != frame {
185+ t.Fatalf("%+v", s.Windows[0])
186+ }
187+ // A bar reaching into the frame shrinks it from the top.
188+ bar.Height, bar.ExclusiveZone = 300, 300
189+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
190+ s = receive(t, r.scenes)[0]
191+ if want := (ports.Rect{X: 760, Y: 300, W: 1920, H: 960}); s.Windows[0].Rect != want || s.WorkspaceClip != frame {
192+ t.Fatalf("%+v clip %+v", s.Windows[0], s.WorkspaceClip)
193+ }
194+}
195+
196+func TestWorkspaceSizeFractionalScaleUnchanged(t *testing.T) {
197+ r, _ := sizedRig(t, "scroll", func(c *ports.Config) {
198+ c.Outputs = []ports.OutputConfig{{Name: "DP-3", Scale: 2}}
199+ })
200+ s := r.mapWindow(t, 1)[0]
201+ // The monitor is 1720x720 logical; a 1920x1080 workspace clamps to it.
202+ if s.OutputWidth != 1720 || s.OutputHeight != 720 || s.Scale != 2 || s.WorkspaceClip != (ports.Rect{}) {
203+ t.Fatalf("%dx%d scale %v clip %+v", s.OutputWidth, s.OutputHeight, s.Scale, s.WorkspaceClip)
204+ }
205+}
206+
207+func TestWorkspaceSizeInputOutsideFrame(t *testing.T) {
208+ r, _ := sizedRig(t, "scroll", nil)
209+ r.mapWindow(t, 1)
210+ r.mapWindow(t, 2)
211+ for len(r.commands) > 0 {
212+ <-r.commands
213+ }
214+ // Inside the frame the window under the pointer gets the focus; window 1
215+ // is scrolled partly off but its visible part outside the frame is dead.
216+ r.input <- ports.PointerMotion{X: 2000, Y: 700}
217+ if v, ok := command(t, r.commands).(ports.PointerFocus); !ok || v.ID != 2 {
218+ t.Fatal(v)
219+ }
220+ r.input <- ports.PointerMotion{X: 100, Y: 700}
221+ for {
222+ if v, ok := command(t, r.commands).(ports.PointerFocus); ok {
223+ if v.ID != 0 {
224+ t.Fatalf("outside the frame: %+v", v)
225+ }
226+ break
227+ }
228+ }
229+}
M internal/ports/config.go +3 -0
@@ -154,6 +154,9 @@ type WorkspaceConfig struct {
154154 // Monitor is the home monitor: a connector name (DP-2) or a monitor key
155155 // ("make model serial"); empty means the first output.
156156 Monitor string
157+ // Size overrides the workspace's logical width and height. Zero inherits
158+ // the monitor; its scale is always inherited.
159+ Size [2]int
157160 // Slots are the declared columns (workspace.<name>.column.N), by N.
158161 Slots []SlotConfig
159162 LayoutRules
@@ -154,6 +154,9 @@ type WorkspaceConfig struct {
154 // Monitor is the home monitor: a connector name (DP-2) or a monitor key154 // Monitor is the home monitor: a connector name (DP-2) or a monitor key
155 // ("make model serial"); empty means the first output.155 // ("make model serial"); empty means the first output.
156 Monitor string156 Monitor string
157+ // Size overrides the workspace's logical width and height. Zero inherits
158+ // the monitor; its scale is always inherited.
159+ Size [2]int
157 // Slots are the declared columns (workspace.<name>.column.N), by N.160 // Slots are the declared columns (workspace.<name>.column.N), by N.
158 Slots []SlotConfig161 Slots []SlotConfig
159 LayoutRules162 LayoutRules
M internal/ports/messages.go +10 -3
@@ -772,8 +772,13 @@ type Scene struct {
772772 Off bool
773773 Background string
774774 Border Border
775+ // WorkspaceClip bounds workspace windows, their popups and separators
776+ // in logical output coordinates. Zero inherits the full output. Layers
777+ // remain output-wide; overview previews do not use this clip.
778+ WorkspaceClip Rect
775779 // Dim darkens the background, bottom layers and tiles (tile lines
776- // included) with black at this opacity, 0 to 1, under the first
780+ // included) inside WorkspaceClip when set, with black at this opacity,
781+ // 0 to 1, under the first
777782 // visible float. 0 draws nothing.
778783 Dim float64
779784 Windows []SceneWindow
@@ -792,10 +797,12 @@ type Scene struct {
792797
793798 // Shows reports whether the scene draws the surface of id: only its
794799 // content changes need a new frame. A window scrolled off the output is
795-// not drawn.
800+// not drawn. WorkspaceClip also excludes off-viewport windows; OverLayers
801+// popups remain output-wide.
796802 func (s Scene) Shows(id WindowID) bool {
797803 for _, w := range s.Windows {
798- if w.ID == id && !w.Hidden && w.Rect.Overlaps(Rect{W: s.OutputWidth, H: s.OutputHeight}) {
804+ if w.ID == id && !w.Hidden && w.Rect.Overlaps(Rect{W: s.OutputWidth, H: s.OutputHeight}) &&
805+ (w.OverLayers || s.WorkspaceClip == (Rect{}) || w.Rect.Overlaps(s.WorkspaceClip)) {
799806 return true
800807 }
801808 }
@@ -772,8 +772,13 @@ type Scene struct {
772 Off bool772 Off bool
773 Background string773 Background string
774 Border Border774 Border Border
775+ // WorkspaceClip bounds workspace windows, their popups and separators
776+ // in logical output coordinates. Zero inherits the full output. Layers
777+ // remain output-wide; overview previews do not use this clip.
778+ WorkspaceClip Rect
775 // Dim darkens the background, bottom layers and tiles (tile lines779 // Dim darkens the background, bottom layers and tiles (tile lines
776- // included) with black at this opacity, 0 to 1, under the first780+ // included) inside WorkspaceClip when set, with black at this opacity,
781+ // 0 to 1, under the first
777 // visible float. 0 draws nothing.782 // visible float. 0 draws nothing.
778 Dim float64783 Dim float64
779 Windows []SceneWindow784 Windows []SceneWindow
@@ -792,10 +797,12 @@ type Scene struct {
792 797
793 // Shows reports whether the scene draws the surface of id: only its798 // Shows reports whether the scene draws the surface of id: only its
794 // content changes need a new frame. A window scrolled off the output is799 // content changes need a new frame. A window scrolled off the output is
795-// not drawn.800+// not drawn. WorkspaceClip also excludes off-viewport windows; OverLayers
801+// popups remain output-wide.
796 func (s Scene) Shows(id WindowID) bool {802 func (s Scene) Shows(id WindowID) bool {
797 for _, w := range s.Windows {803 for _, w := range s.Windows {
798- if w.ID == id && !w.Hidden && w.Rect.Overlaps(Rect{W: s.OutputWidth, H: s.OutputHeight}) {804+ if w.ID == id && !w.Hidden && w.Rect.Overlaps(Rect{W: s.OutputWidth, H: s.OutputHeight}) &&
805+ (w.OverLayers || s.WorkspaceClip == (Rect{}) || w.Rect.Overlaps(s.WorkspaceClip)) {
799 return true806 return true
800 }807 }
801 }808 }
A internal/ports/workspace_clip_test.go +18 -0
@@ -0,0 +1,18 @@
1+package ports
2+
3+import "testing"
4+
5+func TestSceneShowsWorkspaceClip(t *testing.T) {
6+ s := Scene{OutputWidth: 100, OutputHeight: 100, WorkspaceClip: Rect{X: 25, Y: 25, W: 50, H: 50}, Windows: []SceneWindow{
7+ {ID: 1, Rect: Rect{W: 20, H: 20}},
8+ {ID: 2, Rect: Rect{X: 30, Y: 30, W: 20, H: 20}},
9+ {ID: 3, Rect: Rect{W: 20, H: 20}, Popup: true, OverLayers: true},
10+ }}
11+ if s.Shows(1) || !s.Shows(2) || !s.Shows(3) {
12+ t.Fatalf("shown: %v %v %v", s.Shows(1), s.Shows(2), s.Shows(3))
13+ }
14+ s.WorkspaceClip = Rect{}
15+ if !s.Shows(1) {
16+ t.Fatal("inherited workspace excluded surface")
17+ }
18+}
@@ -0,0 +1,18 @@
1+package ports
2+
3+import "testing"
4+
5+func TestSceneShowsWorkspaceClip(t *testing.T) {
6+ s := Scene{OutputWidth: 100, OutputHeight: 100, WorkspaceClip: Rect{X: 25, Y: 25, W: 50, H: 50}, Windows: []SceneWindow{
7+ {ID: 1, Rect: Rect{W: 20, H: 20}},
8+ {ID: 2, Rect: Rect{X: 30, Y: 30, W: 20, H: 20}},
9+ {ID: 3, Rect: Rect{W: 20, H: 20}, Popup: true, OverLayers: true},
10+ }}
11+ if s.Shows(1) || !s.Shows(2) || !s.Shows(3) {
12+ t.Fatalf("shown: %v %v %v", s.Shows(1), s.Shows(2), s.Shows(3))
13+ }
14+ s.WorkspaceClip = Rect{}
15+ if !s.Shows(1) {
16+ t.Fatal("inherited workspace excluded surface")
17+ }
18+}

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 25

Tags 10

↑↓ moveenter openesc close