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 Unverified
B5690EEEBB952194The 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`). | |||
| 40 | 40 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | |
| 41 | 41 | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back | | |
| 42 | 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 | 44 | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | | |
| 44 | 45 | | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` | | |
| 45 | 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 | 55 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | |
| 55 | 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 | 67 | ## Touchpad | |
| 58 | 68 | ||
| 59 | 69 | 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 | ## Touchpad | 67 | ## 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 { | |||
| 713 | 713 | return fmt.Errorf("needs a connector (DP-2) or a monitor key") | |
| 714 | 714 | } | |
| 715 | 715 | 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 | 726 | case "max-columns", "overflow": | |
| 717 | 727 | return setLayoutRule(&w.LayoutRules, field, v) | |
| 718 | 728 | default: | |
| 719 | 729 | n, ok := strings.CutPrefix(field, "column.") | |
| 720 | 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 | 733 | index, err := strconv.Atoi(n) | |
| 724 | 734 | 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 = v | 715 | 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 | |||
| 103 | 103 | // The overlay shows the buffer alone; the lines would go with it. | |
| 104 | 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 | 111 | c := surfaces[pick.ID] | |
| 107 | 112 | pw, ph := float64(pick.Rect.W)*scale, float64(pick.Rect.H)*scale | |
| 108 | 113 | 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)*scale | 112 | 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) { | |||
| 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 | 66 | func TestOverlayCandidate(t *testing.T) { | |
| 44 | 67 | s, c := overlayScene() | |
| 45 | 68 | 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 { | |||
| 66 | 66 | ||
| 67 | 67 | // covers reports whether win fills the output of scene s. | |
| 68 | 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.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 | |
| 70 | 71 | } | |
| 71 | 72 | ||
| 72 | 73 | // fullscreenShown reports whether a visible fullscreen window covers the | |
| @@ -85,6 +86,9 @@ func fullscreenShown(s *ports.Scene) bool { | |||
| 85 | 86 | // directly, or a reason why it cannot. w, h are the output's physical size. | |
| 86 | 87 | // The plane's formats are checked by scanoutFB. | |
| 87 | 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 | 92 | var full *ports.SceneWindow | |
| 89 | 93 | for i := range s.Windows { | |
| 90 | 94 | 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.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 | |||
| 70 | } | 71 | } | ||
| 71 | 72 | ||||
| 72 | // fullscreenShown reports whether a visible fullscreen window covers the | 73 | // 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.SceneWindow | 92 | 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 | |||
| 33 | 33 | w.scale = 1 | |
| 34 | 34 | } | |
| 35 | 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 | 41 | w.windows() | |
| 37 | 42 | w.popups(false) | |
| 43 | + | w.bounds = outputBounds | |
| 38 | 44 | w.layers(true) | |
| 39 | 45 | w.popups(true) | |
| 40 | 46 | r.scratchDraws = w.draws | |
| @@ -251,7 +257,7 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y float64, cl | |||
| 251 | 257 | if near(full.Dx(), sourceW) && near(full.Dy(), sourceH) { | |
| 252 | 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 | 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 | 284 | // that dst shows. key names the surface, seq its content. | |
| 279 | 285 | func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceContent, key shmKey, seq uint64, root bool) { | |
| 280 | 286 | r := w.r | |
| 281 | - | rect := dst.Intersect(w.bounds) | |
| 287 | + | rect := dst | |
| 282 | 288 | if rect.Empty() { | |
| 283 | 289 | return | |
| 284 | 290 | } | |
| @@ -33,8 +33,14 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||||
| 33 | w.scale = 1 | 33 | 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.draws | 46 | 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.r | 286 | r := w.r | ||
| 281 | - | rect := dst.Intersect(w.bounds) | 287 | + | rect := dst |
| 282 | if rect.Empty() { | 288 | if rect.Empty() { | ||
| 283 | return | 289 | 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 { | |||
| 231 | 231 | if err != nil { | |
| 232 | 232 | 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 | 235 | return fmt.Errorf("invalid workspace %q", ws.Name) | |
| 236 | 236 | } | |
| 237 | 237 | 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 | 240 | specs, err := parseSlots(cfg.Workspaces) | |
| 241 | 241 | if err != nil { | |
| @@ -335,7 +335,7 @@ func (c *Core) clientRect(p Placement) Rect { | |||
| 335 | 335 | // onScreen reports whether a placement is drawn on its output o: not | |
| 336 | 336 | // hidden and not scrolled off. | |
| 337 | 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 | 341 | // 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 | 449 | for i, sc := range c.screens { | |
| 450 | 450 | c.seq++ | |
| 451 | 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 | 460 | layout := sc.mon.Layout() | |
| 454 | 461 | var real map[WindowID]Placement | |
| 455 | 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 | 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 | 473 | if sc.mon.ov.open { | |
| 467 | 474 | // Previews have no lines: the selected one is framed. | |
| 468 | 475 | scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2)) | |
| @@ -475,7 +482,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 475 | 482 | sw.Dim = c.cfg.Stash.Dim | |
| 476 | 483 | } | |
| 477 | 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 | 486 | if !p.Hidden && p.Preview == 0 { | |
| 480 | 487 | // Only a sized configure needs the client size. | |
| 481 | 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 | 600 | // and sends it to input when it changed. A hidden window holds nothing. | |
| 594 | 601 | func (c *Core) resolveConstraint() { | |
| 595 | 602 | 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 | 605 | 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) | |
| 601 | 610 | if x1 > x0 && y1 > y0 { | |
| 602 | 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 | 622 | // The cursor follows core; input resyncs to it only on a change. | |
| 608 | 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 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) { | |
| 623 | 650 | if id == 0 { | |
| 624 | - | return Rect{}, false | |
| 651 | + | return Rect{}, Rect{}, false | |
| 625 | 652 | } | |
| 626 | 653 | s, _ := c.screenOf(id) | |
| 627 | 654 | if s == nil { | |
| 628 | - | return Rect{}, false | |
| 655 | + | return Rect{}, Rect{}, false | |
| 629 | 656 | } | |
| 630 | 657 | for _, p := range s.mon.Layout() { | |
| 631 | 658 | if p.ID != id || p.Hidden || p.Preview > 0 { | |
| 632 | 659 | continue | |
| 633 | 660 | } | |
| 634 | 661 | 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 | |
| 638 | 670 | } | |
| 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 | |
| 640 | 678 | } | |
| 641 | 679 | ||
| 642 | 680 | // 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 | 684 | if v.ID == 0 || v.ID != c.pointer || c.grab != 0 && c.grab != v.ID { | |
| 647 | 685 | return nil | |
| 648 | 686 | } | |
| 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) { | |
| 651 | 694 | 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 | 697 | if c.ch.Constraints != nil { | |
| 655 | 698 | g := c.constraint | |
| 656 | 699 | 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 | 767 | // picks a preview (overviewClick). | |
| 725 | 768 | return 0, 0, 0 | |
| 726 | 769 | } | |
| 770 | + | if !sc.mon.frameHas(lx, ly) { | |
| 771 | + | return c.layerAt(sc, lx, ly, false) | |
| 772 | + | } | |
| 727 | 773 | var id WindowID | |
| 728 | 774 | var sx, sy float64 | |
| 729 | 775 | 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 err | 232 | 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] = true | 237 | 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: not | 335 | // 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 is | 341 | // 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]Placement | 461 | 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.Dim | 482 | 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.PointerConstraint | 602 | 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 window | 635 | + | // 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{}, false | 651 | + | 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{}, false | 655 | + | 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 | continue | 659 | continue | ||
| 633 | } | 660 | } | ||
| 634 | r := c.clientRect(p) | 661 | r := c.clientRect(p) | ||
| 635 | - | r.X += s.x | 662 | + | full = Rect{X: r.X + s.x, Y: r.Y + s.y, W: r.W, H: r.H} |
| 636 | - | r.Y += s.y | 663 | + | f := s.mon.Frame() |
| 637 | - | return r, true | 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 | |||
| 638 | } | 670 | } | ||
| 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 | |||
| 640 | } | 678 | } | ||
| 641 | 679 | ||||
| 642 | // warpPointer moves the cursor to a window-local point of the window under | 680 | // 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 nil | 685 | 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 nil | 694 | 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.constraint | 698 | g := c.constraint | ||
| 656 | g.X, g.Y, g.Warp = c.cursorX, c.cursorY, true | 699 | 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, 0 | 768 | 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 WindowID | 773 | var id WindowID | ||
| 728 | var sx, sy float64 | 774 | 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 { | |||
| 137 | 137 | ViewX int | |
| 138 | 138 | // shift slides the columns on screen past ViewX, in logical pixels, | |
| 139 | 139 | // 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 | |
| 146 | 154 | // border is drawn around floating windows, inside their rect. | |
| 147 | 155 | border int | |
| 148 | 156 | 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 | 848 | 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 | |
| 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 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. | |
| 854 | 860 | 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 | |
| 860 | 893 | w.reconcileFloats() | |
| 861 | 894 | w.scroll() | |
| 862 | 895 | } | |
| @@ -872,6 +905,12 @@ func (w *Workspace) SetMaxColumns(n int) { | |||
| 872 | 905 | w.MaxColumns = n | |
| 873 | 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 | 914 | func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) } | |
| 876 | 915 | ||
| 877 | 916 | // pinned reports whether focus moves inside the workspace are off: the | |
| @@ -973,7 +1012,7 @@ func (w *Workspace) scroll() { | |||
| 973 | 1012 | left := w.columnX(w.Focus) - w.ViewX | |
| 974 | 1013 | width := w.columnWidth(w.Focus) | |
| 975 | 1014 | if w.fullscreenColumn(w.Focus) { | |
| 976 | - | w.ViewX += left | |
| 1015 | + | w.ViewX += left - w.Output.X | |
| 977 | 1016 | return | |
| 978 | 1017 | } | |
| 979 | 1018 | minX := w.Usable.X + w.gap() | |
| @@ -1122,9 +1161,9 @@ func (w *Workspace) Layout() []Placement { | |||
| 1122 | 1161 | hidden := (cover != 0 && id != cover) || ((fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full) || maximized | |
| 1123 | 1162 | if full { | |
| 1124 | 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} | |
| 1126 | - | if w.Overflow == OverflowFixed { | |
| 1127 | - | r.X = 0 | |
| 1164 | + | r = w.Output | |
| 1165 | + | if w.Overflow != OverflowFixed { | |
| 1166 | + | r.X = col.X | |
| 1128 | 1167 | } | |
| 1129 | 1168 | } | |
| 1130 | 1169 | if hidden { | |
| @@ -1167,7 +1206,7 @@ func (w *Workspace) Layout() []Placement { | |||
| 1167 | 1206 | continue | |
| 1168 | 1207 | } | |
| 1169 | 1208 | 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 | |
| 1171 | 1210 | } | |
| 1172 | 1211 | result = append(result, p) | |
| 1173 | 1212 | } | |
| @@ -137,12 +137,20 @@ type Workspace struct { | |||||
| 137 | ViewX int | 137 | 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 float64 | 140 | + | shift float64 |
| 141 | - | motion *motion | 141 | + | motion *motion |
| 142 | - | Output Rect | 142 | + | // Output is the effective viewport in monitor coordinates: the whole |
| 143 | - | Usable Rect | 143 | + | // monitor unless the workspace has a size override, then a centered |
| 144 | - | Gaps int | 144 | + | // rectangle no larger than the monitor. Fullscreen fills it. |
| 145 | - | MaxColumns int | 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 | |||
| 146 | // border is drawn around floating windows, inside their rect. | 154 | // border is drawn around floating windows, inside their rect. | ||
| 147 | border int | 155 | border int | ||
| 148 | presets []Width | 156 | 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 = 0 | 850 | + | 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 rectangle | 857 | + | // 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 = r | 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 | |||
| 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 = n | 905 | 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: the | 916 | // 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.ViewX | 1012 | 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 += left | 1015 | + | w.ViewX += left - w.Output.X |
| 977 | return | 1016 | 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) || maximized | 1161 | 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 = 0 | 1166 | + | r.X = col.X |
| 1128 | } | 1167 | } | ||
| 1129 | } | 1168 | } | ||
| 1130 | if hidden { | 1169 | if hidden { | ||
| @@ -1167,7 +1206,7 @@ func (w *Workspace) Layout() []Placement { | |||||
| 1167 | continue | 1206 | 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, 0 | 1209 | + | 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 { | |||
| 49 | 49 | Monitor string | |
| 50 | 50 | MaxColumns int | |
| 51 | 51 | 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 | 57 | func NewMonitor() *Monitor { return newMonitor("", "") } | |
| @@ -362,6 +365,7 @@ func (m *Monitor) ownWorkspace(w *Workspace, id WindowID) bool { | |||
| 362 | 365 | func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) { | |
| 363 | 366 | fs := m.newWorkspace() | |
| 364 | 367 | fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns | |
| 368 | + | fs.SetSize(w.size[0], w.size[1]) | |
| 365 | 369 | fs.origin = w | |
| 366 | 370 | fs.back.id = id | |
| 367 | 371 | if f := w.floatIndex(id); f >= 0 { | |
| @@ -675,8 +679,25 @@ func (m *Monitor) Layout() []Placement { | |||
| 675 | 679 | return m.slideLayout(result) | |
| 676 | 680 | } | |
| 677 | 681 | ||
| 682 | + | // Output is the whole logical monitor, whatever the workspaces' sizes. | |
| 678 | 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 | 701 | func (m *Monitor) each(f func(*Workspace)) { | |
| 681 | 702 | f(&m.template) | |
| 682 | 703 | for _, w := range m.Workspaces { | |
| @@ -737,6 +758,16 @@ func (m *Monitor) applyNamed() { | |||
| 737 | 758 | if s.MaxColumns > 0 { | |
| 738 | 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 | 779 | for _, s := range specs { | |
| 749 | 780 | 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 | 791 | byName := map[string]*Workspace{} | |
| 752 | 792 | for _, w := range m.Workspaces { | |
| 753 | 793 | if _, ok := want[w.Name]; w.Name != "" && !ok { | |
| 754 | 794 | w.Name = "" | |
| 795 | + | w.SetSize(0, 0) | |
| 755 | 796 | } | |
| 756 | 797 | if w.Name != "" { | |
| 757 | 798 | byName[w.Name] = w | |
| @@ -49,6 +49,9 @@ type NamedWorkspace struct { | |||||
| 49 | Monitor string | 49 | Monitor string | ||
| 50 | MaxColumns int | 50 | MaxColumns int | ||
| 51 | Overflow Overflow | 51 | 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.MaxColumns | 367 | fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns | ||
| 368 | + | fs.SetSize(w.size[0], w.size[1]) | |||
| 365 | fs.origin = w | 369 | fs.origin = w | ||
| 366 | fs.back.id = id | 370 | 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] = s | 780 | 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] = w | 798 | byName[w.Name] = w | ||
M internal/core/overview.go +9 -7
| @@ -360,7 +360,7 @@ func (m *Monitor) OverviewPick(id WindowID) { | |||
| 360 | 360 | // with its stash pile and covering-float stack. Other workspaces are hidden. | |
| 361 | 361 | func (m *Monitor) overviewLayout() []Placement { | |
| 362 | 362 | cur := m.Current() | |
| 363 | - | u := cur.Usable | |
| 363 | + | u := cur.overviewArea() | |
| 364 | 364 | gap := u.H * 3 / 100 | |
| 365 | 365 | var result []Placement | |
| 366 | 366 | hc := cur.rowHeight() | |
| @@ -483,12 +483,14 @@ func (w *Workspace) pileWidth() int { | |||
| 483 | 483 | } | |
| 484 | 484 | card := int(math.Round(float64(w.stashRect().W) * overviewCardZoom)) | |
| 485 | 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 | 490 | // cardStep is the offset between stacked cards, smaller on tiny outputs. | |
| 490 | 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 | 496 | // 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 | 509 | front = min(max(front, 0), n-1) | |
| 508 | 510 | r := w.stashRect() | |
| 509 | - | u := w.Usable | |
| 511 | + | u := w.overviewArea() | |
| 510 | 512 | cw := int(math.Round(float64(r.W) * overviewCardZoom)) | |
| 511 | 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 | 515 | // Stack rows reserve two peek steps above the front card. Align the | |
| 514 | 516 | // stash with that front band, not with the top of its envelope. | |
| 515 | 517 | x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2 | |
| @@ -530,7 +532,7 @@ func (w *Workspace) overviewZoom() float64 { | |||
| 530 | 532 | if span <= 0 { | |
| 531 | 533 | return overviewMaxZoom | |
| 532 | 534 | } | |
| 533 | - | room := float64(w.Usable.W-w.pileWidth()) * 0.96 | |
| 535 | + | room := float64(w.overviewArea().W-w.pileWidth()) * 0.96 | |
| 534 | 536 | z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom) | |
| 535 | 537 | if len(w.stack()) > 1 { | |
| 536 | 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 | 628 | func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement { | |
| 627 | - | u := w.Usable | |
| 629 | + | u := w.overviewArea() | |
| 628 | 630 | z := w.overviewZoom() | |
| 629 | 631 | scale := func(v int) int { return int(math.Round(float64(v) * z)) } | |
| 630 | 632 | 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.Usable | 363 | + | u := cur.overviewArea() |
| 364 | gap := u.H * 3 / 100 | 364 | gap := u.H * 3 / 100 | ||
| 365 | var result []Placement | 365 | 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 is | 496 | // 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.Usable | 511 | + | 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 the | 515 | // 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)/2 | 517 | 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 overviewMaxZoom | 533 | return overviewMaxZoom | ||
| 532 | } | 534 | } | ||
| 533 | - | room := float64(w.Usable.W-w.pileWidth()) * 0.96 | 535 | + | 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.Usable | 629 | + | 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) { | |||
| 148 | 148 | if pl.ID == id { | |
| 149 | 149 | // Hidden or scrolled off: not on screen, like its popups. | |
| 150 | 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 | 152 | return nil, Rect{}, false | |
| 153 | 153 | } | |
| 154 | 154 | return sc, c.clientRect(pl), true | |
| @@ -180,7 +180,11 @@ func (c *Core) placePopup(ctx context.Context, v ports.PopupRequest) error { | |||
| 180 | 180 | } | |
| 181 | 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 | 184 | o := sc.mon.Output() | |
| 185 | + | if !c.onLayer(c.popupRoot(v.Parent)) { | |
| 186 | + | o = sc.mon.Frame() | |
| 187 | + | } | |
| 184 | 188 | bounds := Rect{X: o.X - pr.X, Y: o.Y - pr.Y, W: o.W, H: o.H} | |
| 185 | 189 | p := c.popups[v.ID] | |
| 186 | 190 | if p == nil { | |
| @@ -308,6 +312,9 @@ func (c *Core) popupAt(sc *screen, lx, ly float64, overLayers bool) (WindowID, f | |||
| 308 | 312 | if !ok || s != sc { | |
| 309 | 313 | continue | |
| 310 | 314 | } | |
| 315 | + | if !overLayers && !sc.mon.frameHas(lx, ly) { | |
| 316 | + | continue | |
| 317 | + | } | |
| 311 | 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 | 319 | return p.id, lx - float64(r.X), ly - float64(r.Y) | |
| 313 | 320 | } | |
| @@ -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{}, false | 152 | return nil, Rect{}, false | ||
| 153 | } | 153 | } | ||
| 154 | return sc, c.clientRect(pl), true | 154 | 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 | continue | 313 | 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) { | |||
| 85 | 85 | ||
| 86 | 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 | 112 | // slideLayout adds the workspaces a workspace slide shows next to the | |
| 89 | 113 | // current one, moved by their distance, to the current layout (moved too). | |
| 90 | 114 | // A landing slide places them as the list was when the swipe began | |
| 91 | 115 | // (switchList): an empty workspace dropped since shows as background. | |
| 92 | 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 | 118 | return cur | |
| 95 | 119 | } | |
| 96 | 120 | 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 the | 112 | // 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 began | 114 | // 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 cur | 118 | return cur | ||
| 95 | } | 119 | } | ||
| 96 | list := m.Workspaces | 120 | list := m.Workspaces | ||
M internal/core/stash.go +1 -1
| @@ -172,7 +172,7 @@ func (w *Workspace) stashLayout(focusedID, cover WindowID) []Placement { | |||
| 172 | 172 | p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll} | |
| 173 | 173 | switch { | |
| 174 | 174 | 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 | |
| 176 | 176 | case w.stashHidden || cover != 0: | |
| 177 | 177 | p.Hidden = true | |
| 178 | 178 | 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, 0 | 175 | + | 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 = true | 177 | 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 { | |||
| 39 | 39 | // Visible follows the configures: drawn on the output. | |
| 40 | 40 | shown := map[WindowID]bool{} | |
| 41 | 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 | 44 | for _, w := range m.all() { | |
| 45 | 45 | 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) + 1 | 45 | 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 { | |||
| 154 | 154 | // Monitor is the home monitor: a connector name (DP-2) or a monitor key | |
| 155 | 155 | // ("make model serial"); empty means the first output. | |
| 156 | 156 | 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 | 160 | // Slots are the declared columns (workspace.<name>.column.N), by N. | |
| 158 | 161 | Slots []SlotConfig | |
| 159 | 162 | LayoutRules | |
| @@ -154,6 +154,9 @@ type WorkspaceConfig struct { | |||||
| 154 | // Monitor is the home monitor: a connector name (DP-2) or a monitor key | 154 | // 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 string | 156 | 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 []SlotConfig | 161 | Slots []SlotConfig | ||
| 159 | LayoutRules | 162 | LayoutRules | ||
M internal/ports/messages.go +10 -3
| @@ -772,8 +772,13 @@ type Scene struct { | |||
| 772 | 772 | Off bool | |
| 773 | 773 | Background string | |
| 774 | 774 | 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 | 779 | // 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 | |
| 777 | 782 | // visible float. 0 draws nothing. | |
| 778 | 783 | Dim float64 | |
| 779 | 784 | Windows []SceneWindow | |
| @@ -792,10 +797,12 @@ type Scene struct { | |||
| 792 | 797 | ||
| 793 | 798 | // Shows reports whether the scene draws the surface of id: only its | |
| 794 | 799 | // 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 | 802 | func (s Scene) Shows(id WindowID) bool { | |
| 797 | 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 | 806 | return true | |
| 800 | 807 | } | |
| 801 | 808 | } | |
| @@ -772,8 +772,13 @@ type Scene struct { | |||||
| 772 | Off bool | 772 | Off bool | ||
| 773 | Background string | 773 | Background string | ||
| 774 | Border Border | 774 | 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 lines | 779 | // 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 | |||
| 777 | // visible float. 0 draws nothing. | 782 | // visible float. 0 draws nothing. | ||
| 778 | Dim float64 | 783 | Dim float64 | ||
| 779 | Windows []SceneWindow | 784 | Windows []SceneWindow | ||
| @@ -792,10 +797,12 @@ type Scene struct { | |||||
| 792 | 797 | ||||
| 793 | // Shows reports whether the scene draws the surface of id: only its | 798 | // 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 is | 799 | // 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 true | 806 | 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 | + | } | |||