feat(core): add switch-column-next/prev with hold-to-show switcher
- commit
- 1f8087b59cd79bd0a4f666aeb12611a0b46626e1
- author
- brice · · 2026-10-08 17:31 UTC
- parent
- 890c437
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +1121 -24
M internal/core/action.go +4 -0
| @@ -58,6 +58,10 @@ const ( | |||
| 58 | 58 | ActionMoveWorkspaceNext Action = "move-workspace-next" | |
| 59 | 59 | // Turn the focused tile into a free floating window, or back. | |
| 60 | 60 | ActionToggleFloating Action = "toggle-floating" | |
| 61 | + | // Switch to the column used before this one, further back while the | |
| 62 | + | // command key is held (switcher.go). | |
| 63 | + | ActionSwitchColumnNext Action = "switch-column-next" | |
| 64 | + | ActionSwitchColumnPrev Action = "switch-column-prev" | |
| 61 | 65 | ) | |
| 62 | 66 | ||
| 63 | 67 | // monitorDirection returns the direction of the neighbor screen targeted by | |
| @@ -58,6 +58,10 @@ const ( | |||||
| 58 | ActionMoveWorkspaceNext Action = "move-workspace-next" | 58 | ActionMoveWorkspaceNext Action = "move-workspace-next" | ||
| 59 | // Turn the focused tile into a free floating window, or back. | 59 | // Turn the focused tile into a free floating window, or back. | ||
| 60 | ActionToggleFloating Action = "toggle-floating" | 60 | ActionToggleFloating Action = "toggle-floating" | ||
| 61 | + | // Switch to the column used before this one, further back while the | |||
| 62 | + | // command key is held (switcher.go). | |||
| 63 | + | ActionSwitchColumnNext Action = "switch-column-next" | |||
| 64 | + | ActionSwitchColumnPrev Action = "switch-column-prev" | |||
| 61 | ) | 65 | ) | ||
| 62 | 66 | ||||
| 63 | // monitorDirection returns the direction of the neighbor screen targeted by | 67 | // monitorDirection returns the direction of the neighbor screen targeted by | ||
M internal/core/capindicator_test.go +7 -1
| @@ -20,6 +20,12 @@ type indicatorClock struct { | |||
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | func indicatorCore(t *testing.T) (*Core, *indicatorClock) { | |
| 23 | + | t.Helper() | |
| 24 | + | return indicatorCoreWith(t, make(chan ports.ClientCommand, 64)) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // indicatorCoreWith is indicatorCore sending its commands to commands. | |
| 28 | + | func indicatorCoreWith(t *testing.T, commands chan ports.ClientCommand) (*Core, *indicatorClock) { | |
| 23 | 29 | t.Helper() | |
| 24 | 30 | ic := &indicatorClock{now: time.Unix(1000, 0)} | |
| 25 | 31 | clock := portsmocks.NewMockClock(t) | |
| @@ -39,7 +45,7 @@ func indicatorCore(t *testing.T) (*Core, *indicatorClock) { | |||
| 39 | 45 | cfg.Keyboard.CmdKey = "super" | |
| 40 | 46 | cfg.Layout.MaxColumns = 2 | |
| 41 | 47 | cfg.Border.Width = 0 | |
| 42 | - | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)}, Options{Clock: clock}) | |
| 48 | + | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands}, Options{Clock: clock}) | |
| 43 | 49 | if err != nil { | |
| 44 | 50 | t.Fatal(err) | |
| 45 | 51 | } | |
| @@ -20,6 +20,12 @@ type indicatorClock struct { | |||||
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
| 22 | func indicatorCore(t *testing.T) (*Core, *indicatorClock) { | 22 | func indicatorCore(t *testing.T) (*Core, *indicatorClock) { | ||
| 23 | + | t.Helper() | |||
| 24 | + | return indicatorCoreWith(t, make(chan ports.ClientCommand, 64)) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // indicatorCoreWith is indicatorCore sending its commands to commands. | |||
| 28 | + | func indicatorCoreWith(t *testing.T, commands chan ports.ClientCommand) (*Core, *indicatorClock) { | |||
| 23 | t.Helper() | 29 | t.Helper() | ||
| 24 | ic := &indicatorClock{now: time.Unix(1000, 0)} | 30 | ic := &indicatorClock{now: time.Unix(1000, 0)} | ||
| 25 | clock := portsmocks.NewMockClock(t) | 31 | clock := portsmocks.NewMockClock(t) | ||
| @@ -39,7 +45,7 @@ func indicatorCore(t *testing.T) (*Core, *indicatorClock) { | |||||
| 39 | cfg.Keyboard.CmdKey = "super" | 45 | cfg.Keyboard.CmdKey = "super" | ||
| 40 | cfg.Layout.MaxColumns = 2 | 46 | cfg.Layout.MaxColumns = 2 | ||
| 41 | cfg.Border.Width = 0 | 47 | cfg.Border.Width = 0 | ||
| 42 | - | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)}, Options{Clock: clock}) | 48 | + | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands}, Options{Clock: clock}) |
| 43 | if err != nil { | 49 | if err != nil { | ||
| 44 | t.Fatal(err) | 50 | t.Fatal(err) | ||
| 45 | } | 51 | } | ||
M internal/core/capture.go +2 -2
| @@ -153,7 +153,7 @@ func (c *Core) capResolve(t capTarget) (capResolved, ports.CaptureReason) { | |||
| 153 | 153 | return r, ports.CaptureReasonWorkspaceGone | |
| 154 | 154 | } | |
| 155 | 155 | r.sc, r.ws, r.rect = sc, w, w.Output | |
| 156 | - | r.hidden = sc.mon.Current() != w || sc.mon.ov.open | |
| 156 | + | r.hidden = sc.mon.Current() != w || sc.mon.previewing() | |
| 157 | 157 | return r, ports.CaptureReasonNone | |
| 158 | 158 | } | |
| 159 | 159 | i := c.screenIndex(t.output) | |
| @@ -467,7 +467,7 @@ func (c *Core) captureSceneFor(sc *screen, v *capView) *ports.SceneCapture { | |||
| 467 | 467 | return nil | |
| 468 | 468 | } | |
| 469 | 469 | want := ports.SceneCapture{} | |
| 470 | - | if !sc.mon.ov.open { | |
| 470 | + | if !sc.mon.previewing() { | |
| 471 | 471 | want.Shown = sc.mon.Current().ID | |
| 472 | 472 | } | |
| 473 | 473 | if e := v.exclusion; e != nil { | |
| @@ -153,7 +153,7 @@ func (c *Core) capResolve(t capTarget) (capResolved, ports.CaptureReason) { | |||||
| 153 | return r, ports.CaptureReasonWorkspaceGone | 153 | return r, ports.CaptureReasonWorkspaceGone | ||
| 154 | } | 154 | } | ||
| 155 | r.sc, r.ws, r.rect = sc, w, w.Output | 155 | r.sc, r.ws, r.rect = sc, w, w.Output | ||
| 156 | - | r.hidden = sc.mon.Current() != w || sc.mon.ov.open | 156 | + | r.hidden = sc.mon.Current() != w || sc.mon.previewing() |
| 157 | return r, ports.CaptureReasonNone | 157 | return r, ports.CaptureReasonNone | ||
| 158 | } | 158 | } | ||
| 159 | i := c.screenIndex(t.output) | 159 | i := c.screenIndex(t.output) | ||
| @@ -467,7 +467,7 @@ func (c *Core) captureSceneFor(sc *screen, v *capView) *ports.SceneCapture { | |||||
| 467 | return nil | 467 | return nil | ||
| 468 | } | 468 | } | ||
| 469 | want := ports.SceneCapture{} | 469 | want := ports.SceneCapture{} | ||
| 470 | - | if !sc.mon.ov.open { | 470 | + | if !sc.mon.previewing() { |
| 471 | want.Shown = sc.mon.Current().ID | 471 | want.Shown = sc.mon.Current().ID | ||
| 472 | } | 472 | } | ||
| 473 | if e := v.exclusion; e != nil { | 473 | if e := v.exclusion; e != nil { | ||
M internal/core/core.go +19 -3
| @@ -152,6 +152,13 @@ type Core struct { | |||
| 152 | 152 | capt captureState | |
| 153 | 153 | // pulse marks a window that just got the focus (pulse.go). | |
| 154 | 154 | pulse focusPulse | |
| 155 | + | // switcher is the column switcher in progress: the screen it is on and | |
| 156 | + | // the timer that shows its cards (switcher.go). | |
| 157 | + | switcher struct { | |
| 158 | + | sc *screen | |
| 159 | + | timerC <-chan time.Time | |
| 160 | + | timerStop func() bool | |
| 161 | + | } | |
| 155 | 162 | } | |
| 156 | 163 | ||
| 157 | 164 | // publishScratch is where publishState and publishWorkspaces build their | |
| @@ -263,7 +270,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 263 | 270 | } | |
| 264 | 271 | switch Action(a) { | |
| 265 | 272 | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext, ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, | |
| 266 | - | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating: | |
| 273 | + | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, ActionSwitchColumnPrev: | |
| 267 | 274 | default: | |
| 268 | 275 | return fmt.Errorf("invalid action %q", a) | |
| 269 | 276 | } | |
| @@ -975,9 +982,9 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 975 | 982 | if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 { | |
| 976 | 983 | return id, px, py | |
| 977 | 984 | } | |
| 978 | - | if sc.mon.ov.open { | |
| 985 | + | if sc.mon.previewing() { | |
| 979 | 986 | // Previews and the layers under them take no input: a click | |
| 980 | - | // picks a preview (overviewClick). | |
| 987 | + | // picks a preview (overviewClick, switcherClick). | |
| 981 | 988 | return 0, 0, 0 | |
| 982 | 989 | } | |
| 983 | 990 | if !sc.mon.frameHas(lx, ly) { | |
| @@ -1017,6 +1024,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1017 | 1024 | defer c.stopFrame() | |
| 1018 | 1025 | defer c.stopCaptureTimer() | |
| 1019 | 1026 | defer c.stopPulseTimer() | |
| 1027 | + | defer c.cancelSwitcher() | |
| 1020 | 1028 | // Startup commands run once per session. Full launcher queues retain a | |
| 1021 | 1029 | // bounded remainder, selectable alongside owner events without polling. | |
| 1022 | 1030 | c.startup = make([][]string, len(c.cfg.Startup)) | |
| @@ -1089,6 +1097,14 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1089 | 1097 | return nil | |
| 1090 | 1098 | } | |
| 1091 | 1099 | continue | |
| 1100 | + | case <-c.switcher.timerC: | |
| 1101 | + | if c.securityCheckpoint(ctx) != nil { | |
| 1102 | + | return nil | |
| 1103 | + | } | |
| 1104 | + | if c.switcherTick() && (c.publish(ctx) != nil || c.rehit(ctx) != nil) { | |
| 1105 | + | return nil | |
| 1106 | + | } | |
| 1107 | + | continue | |
| 1092 | 1108 | case <-c.capt.timerC: | |
| 1093 | 1109 | c.capt.timerC, c.capt.timerStop = nil, nil | |
| 1094 | 1110 | if c.captureFlashTick() && c.publish(ctx) != nil { | |
| @@ -152,6 +152,13 @@ type Core struct { | |||||
| 152 | capt captureState | 152 | capt captureState | ||
| 153 | // pulse marks a window that just got the focus (pulse.go). | 153 | // pulse marks a window that just got the focus (pulse.go). | ||
| 154 | pulse focusPulse | 154 | pulse focusPulse | ||
| 155 | + | // switcher is the column switcher in progress: the screen it is on and | |||
| 156 | + | // the timer that shows its cards (switcher.go). | |||
| 157 | + | switcher struct { | |||
| 158 | + | sc *screen | |||
| 159 | + | timerC <-chan time.Time | |||
| 160 | + | timerStop func() bool | |||
| 161 | + | } | |||
| 155 | } | 162 | } | ||
| 156 | 163 | ||||
| 157 | // publishScratch is where publishState and publishWorkspaces build their | 164 | // publishScratch is where publishState and publishWorkspaces build their | ||
| @@ -263,7 +270,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 263 | } | 270 | } | ||
| 264 | switch Action(a) { | 271 | switch Action(a) { | ||
| 265 | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext, ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, | 272 | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext, ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, | ||
| 266 | - | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating: | 273 | + | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, ActionSwitchColumnPrev: |
| 267 | default: | 274 | default: | ||
| 268 | return fmt.Errorf("invalid action %q", a) | 275 | return fmt.Errorf("invalid action %q", a) | ||
| 269 | } | 276 | } | ||
| @@ -975,9 +982,9 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||||
| 975 | if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 { | 982 | if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 { | ||
| 976 | return id, px, py | 983 | return id, px, py | ||
| 977 | } | 984 | } | ||
| 978 | - | if sc.mon.ov.open { | 985 | + | if sc.mon.previewing() { |
| 979 | // Previews and the layers under them take no input: a click | 986 | // Previews and the layers under them take no input: a click | ||
| 980 | - | // picks a preview (overviewClick). | 987 | + | // picks a preview (overviewClick, switcherClick). |
| 981 | return 0, 0, 0 | 988 | return 0, 0, 0 | ||
| 982 | } | 989 | } | ||
| 983 | if !sc.mon.frameHas(lx, ly) { | 990 | if !sc.mon.frameHas(lx, ly) { | ||
| @@ -1017,6 +1024,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1017 | defer c.stopFrame() | 1024 | defer c.stopFrame() | ||
| 1018 | defer c.stopCaptureTimer() | 1025 | defer c.stopCaptureTimer() | ||
| 1019 | defer c.stopPulseTimer() | 1026 | defer c.stopPulseTimer() | ||
| 1027 | + | defer c.cancelSwitcher() | |||
| 1020 | // Startup commands run once per session. Full launcher queues retain a | 1028 | // Startup commands run once per session. Full launcher queues retain a | ||
| 1021 | // bounded remainder, selectable alongside owner events without polling. | 1029 | // bounded remainder, selectable alongside owner events without polling. | ||
| 1022 | c.startup = make([][]string, len(c.cfg.Startup)) | 1030 | c.startup = make([][]string, len(c.cfg.Startup)) | ||
| @@ -1089,6 +1097,14 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1089 | return nil | 1097 | return nil | ||
| 1090 | } | 1098 | } | ||
| 1091 | continue | 1099 | continue | ||
| 1100 | + | case <-c.switcher.timerC: | |||
| 1101 | + | if c.securityCheckpoint(ctx) != nil { | |||
| 1102 | + | return nil | |||
| 1103 | + | } | |||
| 1104 | + | if c.switcherTick() && (c.publish(ctx) != nil || c.rehit(ctx) != nil) { | |||
| 1105 | + | return nil | |||
| 1106 | + | } | |||
| 1107 | + | continue | |||
| 1092 | case <-c.capt.timerC: | 1108 | case <-c.capt.timerC: | ||
| 1093 | c.capt.timerC, c.capt.timerStop = nil, nil | 1109 | c.capt.timerC, c.capt.timerStop = nil, nil | ||
| 1094 | if c.captureFlashTick() && c.publish(ctx) != nil { | 1110 | if c.captureFlashTick() && c.publish(ctx) != nil { | ||
M internal/core/drag.go +2 -2
| @@ -78,7 +78,7 @@ type dragState struct { | |||
| 78 | 78 | // not fullscreen, outside the overview. | |
| 79 | 79 | func (c *Core) draggable(id WindowID) (*screen, *Workspace, bool) { | |
| 80 | 80 | s, w := c.screenOf(id) | |
| 81 | - | if s == nil || w != s.mon.Current() || s.mon.ov.open || id == w.fullscreen || w.cover() != 0 { | |
| 81 | + | if s == nil || w != s.mon.Current() || s.mon.previewing() || id == w.fullscreen || w.cover() != 0 { | |
| 82 | 82 | return nil, nil, false | |
| 83 | 83 | } | |
| 84 | 84 | if w.floatIndex(id) < 0 && w.columnOf(id) < 0 { | |
| @@ -288,7 +288,7 @@ func (c *Core) dropAt(id WindowID, x, y float64) dropTarget { | |||
| 288 | 288 | m := sc.mon | |
| 289 | 289 | lx, ly := int(x)-sc.x, int(y)-sc.y | |
| 290 | 290 | w := m.Current() | |
| 291 | - | if m.ov.open || w.pinned() || !m.frameHas(float64(lx), float64(ly)) { | |
| 291 | + | if m.previewing() || w.pinned() || !m.frameHas(float64(lx), float64(ly)) { | |
| 292 | 292 | return dropTarget{} | |
| 293 | 293 | } | |
| 294 | 294 | t := w.dropIn(id, lx, ly, max(4*c.cfg.Border.Width, 4)) | |
| @@ -78,7 +78,7 @@ type dragState struct { | |||||
| 78 | // not fullscreen, outside the overview. | 78 | // not fullscreen, outside the overview. | ||
| 79 | func (c *Core) draggable(id WindowID) (*screen, *Workspace, bool) { | 79 | func (c *Core) draggable(id WindowID) (*screen, *Workspace, bool) { | ||
| 80 | s, w := c.screenOf(id) | 80 | s, w := c.screenOf(id) | ||
| 81 | - | if s == nil || w != s.mon.Current() || s.mon.ov.open || id == w.fullscreen || w.cover() != 0 { | 81 | + | if s == nil || w != s.mon.Current() || s.mon.previewing() || id == w.fullscreen || w.cover() != 0 { |
| 82 | return nil, nil, false | 82 | return nil, nil, false | ||
| 83 | } | 83 | } | ||
| 84 | if w.floatIndex(id) < 0 && w.columnOf(id) < 0 { | 84 | if w.floatIndex(id) < 0 && w.columnOf(id) < 0 { | ||
| @@ -288,7 +288,7 @@ func (c *Core) dropAt(id WindowID, x, y float64) dropTarget { | |||||
| 288 | m := sc.mon | 288 | m := sc.mon | ||
| 289 | lx, ly := int(x)-sc.x, int(y)-sc.y | 289 | lx, ly := int(x)-sc.x, int(y)-sc.y | ||
| 290 | w := m.Current() | 290 | w := m.Current() | ||
| 291 | - | if m.ov.open || w.pinned() || !m.frameHas(float64(lx), float64(ly)) { | 291 | + | if m.previewing() || w.pinned() || !m.frameHas(float64(lx), float64(ly)) { |
| 292 | return dropTarget{} | 292 | return dropTarget{} | ||
| 293 | } | 293 | } | ||
| 294 | t := w.dropIn(id, lx, ly, max(4*c.cfg.Border.Width, 4)) | 294 | t := w.dropIn(id, lx, ly, max(4*c.cfg.Border.Width, 4)) | ||
M internal/core/gesture.go +4 -0
| @@ -138,6 +138,10 @@ func (c *Core) swipeSign() float64 { | |||
| 138 | 138 | // running (its end was lost) ends cancelled first: its slide settles back. | |
| 139 | 139 | func (c *Core) swipeBegin(b ports.SwipeBegin) bool { | |
| 140 | 140 | changed := false | |
| 141 | + | if c.switching() { | |
| 142 | + | c.cancelSwitcher() | |
| 143 | + | changed = true | |
| 144 | + | } | |
| 141 | 145 | if c.swipe != nil { | |
| 142 | 146 | c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: b.Time}) | |
| 143 | 147 | changed = true | |
| @@ -138,6 +138,10 @@ func (c *Core) swipeSign() float64 { | |||||
| 138 | // running (its end was lost) ends cancelled first: its slide settles back. | 138 | // running (its end was lost) ends cancelled first: its slide settles back. | ||
| 139 | func (c *Core) swipeBegin(b ports.SwipeBegin) bool { | 139 | func (c *Core) swipeBegin(b ports.SwipeBegin) bool { | ||
| 140 | changed := false | 140 | changed := false | ||
| 141 | + | if c.switching() { | |||
| 142 | + | c.cancelSwitcher() | |||
| 143 | + | changed = true | |||
| 144 | + | } | |||
| 141 | if c.swipe != nil { | 145 | if c.swipe != nil { | ||
| 142 | c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: b.Time}) | 146 | c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: b.Time}) | ||
| 143 | changed = true | 147 | changed = true | ||
M internal/core/input.go +23 -0
| @@ -128,6 +128,11 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error { | |||
| 128 | 128 | } | |
| 129 | 129 | return nil | |
| 130 | 130 | } | |
| 131 | + | if v.Pressed && c.switching() { | |
| 132 | + | if handled, err := c.switcherClick(ctx, v.Button); err != nil || handled { | |
| 133 | + | return err | |
| 134 | + | } | |
| 135 | + | } | |
| 131 | 136 | if started, err := c.startDrag(ctx, v); err != nil || started { | |
| 132 | 137 | return err | |
| 133 | 138 | } | |
| @@ -224,6 +229,18 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error { | |||
| 224 | 229 | ||
| 225 | 230 | func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | |
| 226 | 231 | c.mods = key.Mods | |
| 232 | + | // Releasing the command key commits the switcher; the release still | |
| 233 | + | // goes on to the bind lookup and the clients. | |
| 234 | + | if c.switching() && c.mods&c.cmdMod == 0 { | |
| 235 | + | if err := c.releaseSwitcher(ctx); err != nil { | |
| 236 | + | return err | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | if c.switching() && key.Pressed && keyName(key.Keysym) == "Escape" { | |
| 240 | + | // Escape cancels the switcher; its release goes nowhere either. | |
| 241 | + | c.pressed[heldKey(key)] = true | |
| 242 | + | return c.abortSwitcher(ctx) | |
| 243 | + | } | |
| 227 | 244 | if c.drag != nil && key.Pressed && key.Keysym == "Escape" { | |
| 228 | 245 | // Escape cancels the drag; its release goes nowhere either. | |
| 229 | 246 | c.cancelDrag() | |
| @@ -308,6 +325,12 @@ func (c *Core) runBind(ctx context.Context, action Action) error { | |||
| 308 | 325 | if action == ActionScaleUp || action == ActionScaleDown { | |
| 309 | 326 | return c.scaleBind(ctx, action) | |
| 310 | 327 | } | |
| 328 | + | if dir := switchDir(action); dir != 0 { | |
| 329 | + | return c.switchBind(ctx, dir) | |
| 330 | + | } | |
| 331 | + | if c.switching() { | |
| 332 | + | c.cancelSwitcher() | |
| 333 | + | } | |
| 311 | 334 | before := c.cur().mon.Current() | |
| 312 | 335 | swiped := c.swipedWorkspace() | |
| 313 | 336 | now := c.now() | |
| @@ -128,6 +128,11 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error { | |||||
| 128 | } | 128 | } | ||
| 129 | return nil | 129 | return nil | ||
| 130 | } | 130 | } | ||
| 131 | + | if v.Pressed && c.switching() { | |||
| 132 | + | if handled, err := c.switcherClick(ctx, v.Button); err != nil || handled { | |||
| 133 | + | return err | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 131 | if started, err := c.startDrag(ctx, v); err != nil || started { | 136 | if started, err := c.startDrag(ctx, v); err != nil || started { | ||
| 132 | return err | 137 | return err | ||
| 133 | } | 138 | } | ||
| @@ -224,6 +229,18 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error { | |||||
| 224 | 229 | ||||
| 225 | func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | 230 | func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | ||
| 226 | c.mods = key.Mods | 231 | c.mods = key.Mods | ||
| 232 | + | // Releasing the command key commits the switcher; the release still | |||
| 233 | + | // goes on to the bind lookup and the clients. | |||
| 234 | + | if c.switching() && c.mods&c.cmdMod == 0 { | |||
| 235 | + | if err := c.releaseSwitcher(ctx); err != nil { | |||
| 236 | + | return err | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | if c.switching() && key.Pressed && keyName(key.Keysym) == "Escape" { | |||
| 240 | + | // Escape cancels the switcher; its release goes nowhere either. | |||
| 241 | + | c.pressed[heldKey(key)] = true | |||
| 242 | + | return c.abortSwitcher(ctx) | |||
| 243 | + | } | |||
| 227 | if c.drag != nil && key.Pressed && key.Keysym == "Escape" { | 244 | if c.drag != nil && key.Pressed && key.Keysym == "Escape" { | ||
| 228 | // Escape cancels the drag; its release goes nowhere either. | 245 | // Escape cancels the drag; its release goes nowhere either. | ||
| 229 | c.cancelDrag() | 246 | c.cancelDrag() | ||
| @@ -308,6 +325,12 @@ func (c *Core) runBind(ctx context.Context, action Action) error { | |||||
| 308 | if action == ActionScaleUp || action == ActionScaleDown { | 325 | if action == ActionScaleUp || action == ActionScaleDown { | ||
| 309 | return c.scaleBind(ctx, action) | 326 | return c.scaleBind(ctx, action) | ||
| 310 | } | 327 | } | ||
| 328 | + | if dir := switchDir(action); dir != 0 { | |||
| 329 | + | return c.switchBind(ctx, dir) | |||
| 330 | + | } | |||
| 331 | + | if c.switching() { | |||
| 332 | + | c.cancelSwitcher() | |||
| 333 | + | } | |||
| 311 | before := c.cur().mon.Current() | 334 | before := c.cur().mon.Current() | ||
| 312 | swiped := c.swipedWorkspace() | 335 | swiped := c.swipedWorkspace() | ||
| 313 | now := c.now() | 336 | now := c.now() | ||
M internal/core/layers.go +1 -1
| @@ -90,7 +90,7 @@ func (c *Core) layerOf(id WindowID) (*screen, ports.SceneLayer, bool) { | |||
| 90 | 90 | // scroll overflow, one scrolled off by the user no longer does; nor does | |
| 91 | 91 | // one shown as an overview preview. | |
| 92 | 92 | func hasFullscreen(sc *screen) bool { | |
| 93 | - | return !sc.mon.ov.open && sc.mon.Current().cover() != 0 | |
| 93 | + | return !sc.mon.previewing() && sc.mon.Current().cover() != 0 | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | 96 | // shown reports whether a layer surface is drawn. A fullscreen window is | |
| @@ -90,7 +90,7 @@ func (c *Core) layerOf(id WindowID) (*screen, ports.SceneLayer, bool) { | |||||
| 90 | // scroll overflow, one scrolled off by the user no longer does; nor does | 90 | // scroll overflow, one scrolled off by the user no longer does; nor does | ||
| 91 | // one shown as an overview preview. | 91 | // one shown as an overview preview. | ||
| 92 | func hasFullscreen(sc *screen) bool { | 92 | func hasFullscreen(sc *screen) bool { | ||
| 93 | - | return !sc.mon.ov.open && sc.mon.Current().cover() != 0 | 93 | + | return !sc.mon.previewing() && sc.mon.Current().cover() != 0 |
| 94 | } | 94 | } | ||
| 95 | 95 | ||||
| 96 | // shown reports whether a layer surface is drawn. A fullscreen window is | 96 | // shown reports whether a layer surface is drawn. A fullscreen window is | ||
M internal/core/monitor.go +5 -1
| @@ -333,6 +333,7 @@ func (m *Monitor) RemoveWindow(id WindowID) { | |||
| 333 | 333 | w.RemoveWindow(id) | |
| 334 | 334 | m.normalize() | |
| 335 | 335 | } | |
| 336 | + | m.sw.remove(id) | |
| 336 | 337 | } | |
| 337 | 338 | ||
| 338 | 339 | // SetFullscreen applies a client request, in place. A client request never | |
| @@ -425,6 +426,9 @@ func (m *Monitor) Layout() []Placement { return m.layoutInto(nil) } | |||
| 425 | 426 | // so a reused dst's previous result is overwritten and only a caller done | |
| 426 | 427 | // with it may pass it. It is never the monitor's own scratch. | |
| 427 | 428 | func (m *Monitor) layoutInto(dst []Placement) []Placement { | |
| 429 | + | if m.sw.shown && m.sw.ws == m.Current() { | |
| 430 | + | return m.switcherLayout(dst[:0]) | |
| 431 | + | } | |
| 428 | 432 | if m.ov.open { | |
| 429 | 433 | return m.overviewLayout(dst[:0]) | |
| 430 | 434 | } | |
| @@ -447,7 +451,7 @@ func (m *Monitor) Output() Rect { return m.template.Output } | |||
| 447 | 451 | // coordinates: its viewport, or the whole monitor in the overview. Slides | |
| 448 | 452 | // involving a sized workspace remain settled inside the current frame. | |
| 449 | 453 | func (m *Monitor) Frame() Rect { | |
| 450 | - | if m.ov.open { | |
| 454 | + | if m.previewing() { | |
| 451 | 455 | return m.Output() | |
| 452 | 456 | } | |
| 453 | 457 | return m.Current().Output | |
| @@ -333,6 +333,7 @@ func (m *Monitor) RemoveWindow(id WindowID) { | |||||
| 333 | w.RemoveWindow(id) | 333 | w.RemoveWindow(id) | ||
| 334 | m.normalize() | 334 | m.normalize() | ||
| 335 | } | 335 | } | ||
| 336 | + | m.sw.remove(id) | |||
| 336 | } | 337 | } | ||
| 337 | 338 | ||||
| 338 | // SetFullscreen applies a client request, in place. A client request never | 339 | // SetFullscreen applies a client request, in place. A client request never | ||
| @@ -425,6 +426,9 @@ func (m *Monitor) Layout() []Placement { return m.layoutInto(nil) } | |||||
| 425 | // so a reused dst's previous result is overwritten and only a caller done | 426 | // so a reused dst's previous result is overwritten and only a caller done | ||
| 426 | // with it may pass it. It is never the monitor's own scratch. | 427 | // with it may pass it. It is never the monitor's own scratch. | ||
| 427 | func (m *Monitor) layoutInto(dst []Placement) []Placement { | 428 | func (m *Monitor) layoutInto(dst []Placement) []Placement { | ||
| 429 | + | if m.sw.shown && m.sw.ws == m.Current() { | |||
| 430 | + | return m.switcherLayout(dst[:0]) | |||
| 431 | + | } | |||
| 428 | if m.ov.open { | 432 | if m.ov.open { | ||
| 429 | return m.overviewLayout(dst[:0]) | 433 | return m.overviewLayout(dst[:0]) | ||
| 430 | } | 434 | } | ||
| @@ -447,7 +451,7 @@ func (m *Monitor) Output() Rect { return m.template.Output } | |||||
| 447 | // coordinates: its viewport, or the whole monitor in the overview. Slides | 451 | // coordinates: its viewport, or the whole monitor in the overview. Slides | ||
| 448 | // involving a sized workspace remain settled inside the current frame. | 452 | // involving a sized workspace remain settled inside the current frame. | ||
| 449 | func (m *Monitor) Frame() Rect { | 453 | func (m *Monitor) Frame() Rect { | ||
| 450 | - | if m.ov.open { | 454 | + | if m.previewing() { |
| 451 | return m.Output() | 455 | return m.Output() | ||
| 452 | } | 456 | } | ||
| 453 | return m.Current().Output | 457 | return m.Current().Output | ||
M internal/core/overview.go +2 -1
| @@ -202,7 +202,8 @@ func overviewBlocks(a Action) bool { | |||
| 202 | 202 | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, | |
| 203 | 203 | ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, | |
| 204 | 204 | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, | |
| 205 | - | ActionMoveWorkspaceNext, ActionToggleFloating: | |
| 205 | + | ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, | |
| 206 | + | ActionSwitchColumnPrev: | |
| 206 | 207 | return true | |
| 207 | 208 | } | |
| 208 | 209 | return false | |
| @@ -202,7 +202,8 @@ func overviewBlocks(a Action) bool { | |||||
| 202 | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, | 202 | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, | ||
| 203 | ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, | 203 | ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, | ||
| 204 | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, | 204 | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, | ||
| 205 | - | ActionMoveWorkspaceNext, ActionToggleFloating: | 205 | + | ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, |
| 206 | + | ActionSwitchColumnPrev: | |||
| 206 | return true | 207 | return true | ||
| 207 | } | 208 | } | ||
| 208 | return false | 209 | return false | ||
M internal/core/scene.go +7 -4
| @@ -22,14 +22,14 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||
| 22 | 22 | // windows are going, and alone sizes the configures. | |
| 23 | 23 | layout, settled := sc.shown, sc.settledLayout | |
| 24 | 24 | scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)), Layers: shownLayers(sc)} | |
| 25 | - | if !sc.mon.ov.open { | |
| 25 | + | if !sc.mon.previewing() { | |
| 26 | 26 | // Tiles stop at the panels; the overview lays out previews over | |
| 27 | 27 | // the whole output. | |
| 28 | 28 | u := view.usable | |
| 29 | 29 | scene.TileInset = ports.Insets{Top: u.Y, Left: u.X, Right: o.W - u.X - u.W, Bottom: o.H - u.Y - u.H} | |
| 30 | 30 | } | |
| 31 | 31 | var real map[WindowID]Placement | |
| 32 | - | if sc.mon.ov.open { | |
| 32 | + | if sc.mon.previewing() { | |
| 33 | 33 | // Local to this screen's build: reused, cleared each time. | |
| 34 | 34 | if c.overviewReal == nil { | |
| 35 | 35 | c.overviewReal = make(map[WindowID]Placement) | |
| @@ -44,7 +44,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||
| 44 | 44 | } | |
| 45 | 45 | } | |
| 46 | 46 | scene.Dim = floatDim(layout, view, c.cfg.Floating.Dim) | |
| 47 | - | if sc.mon.ov.open { | |
| 47 | + | if sc.mon.previewing() { | |
| 48 | 48 | // Darken the wallpaper around the previews. | |
| 49 | 49 | scene.Dim = c.cfg.Floating.Dim | |
| 50 | 50 | scene.DimBehind = scene.Dim > 0 | |
| @@ -56,6 +56,9 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||
| 56 | 56 | // Frame the selection and separate numbered and named row groups. | |
| 57 | 57 | _, scene.Separators = sc.mon.overviewRows() | |
| 58 | 58 | scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...) | |
| 59 | + | } else if sc.mon.sw.shown { | |
| 60 | + | // The switcher's cards: only the selection is framed. | |
| 61 | + | scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2)) | |
| 59 | 62 | } else { | |
| 60 | 63 | // Only the focused output lights the focused window's lines. | |
| 61 | 64 | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen) | |
| @@ -89,7 +92,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||
| 89 | 92 | } | |
| 90 | 93 | continue | |
| 91 | 94 | } | |
| 92 | - | if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open && hasRoom(view, p) { | |
| 95 | + | if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.previewing() && hasRoom(view, p) { | |
| 93 | 96 | drawable = true | |
| 94 | 97 | if p.ID == c.pulse.id { | |
| 95 | 98 | sw.FocusEffect = pulse | |
| @@ -22,14 +22,14 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||||
| 22 | // windows are going, and alone sizes the configures. | 22 | // windows are going, and alone sizes the configures. | ||
| 23 | layout, settled := sc.shown, sc.settledLayout | 23 | layout, settled := sc.shown, sc.settledLayout | ||
| 24 | scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)), Layers: shownLayers(sc)} | 24 | scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)), Layers: shownLayers(sc)} | ||
| 25 | - | if !sc.mon.ov.open { | 25 | + | if !sc.mon.previewing() { |
| 26 | // Tiles stop at the panels; the overview lays out previews over | 26 | // Tiles stop at the panels; the overview lays out previews over | ||
| 27 | // the whole output. | 27 | // the whole output. | ||
| 28 | u := view.usable | 28 | u := view.usable | ||
| 29 | scene.TileInset = ports.Insets{Top: u.Y, Left: u.X, Right: o.W - u.X - u.W, Bottom: o.H - u.Y - u.H} | 29 | scene.TileInset = ports.Insets{Top: u.Y, Left: u.X, Right: o.W - u.X - u.W, Bottom: o.H - u.Y - u.H} | ||
| 30 | } | 30 | } | ||
| 31 | var real map[WindowID]Placement | 31 | var real map[WindowID]Placement | ||
| 32 | - | if sc.mon.ov.open { | 32 | + | if sc.mon.previewing() { |
| 33 | // Local to this screen's build: reused, cleared each time. | 33 | // Local to this screen's build: reused, cleared each time. | ||
| 34 | if c.overviewReal == nil { | 34 | if c.overviewReal == nil { | ||
| 35 | c.overviewReal = make(map[WindowID]Placement) | 35 | c.overviewReal = make(map[WindowID]Placement) | ||
| @@ -44,7 +44,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||||
| 44 | } | 44 | } | ||
| 45 | } | 45 | } | ||
| 46 | scene.Dim = floatDim(layout, view, c.cfg.Floating.Dim) | 46 | scene.Dim = floatDim(layout, view, c.cfg.Floating.Dim) | ||
| 47 | - | if sc.mon.ov.open { | 47 | + | if sc.mon.previewing() { |
| 48 | // Darken the wallpaper around the previews. | 48 | // Darken the wallpaper around the previews. | ||
| 49 | scene.Dim = c.cfg.Floating.Dim | 49 | scene.Dim = c.cfg.Floating.Dim | ||
| 50 | scene.DimBehind = scene.Dim > 0 | 50 | scene.DimBehind = scene.Dim > 0 | ||
| @@ -56,6 +56,9 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||||
| 56 | // Frame the selection and separate numbered and named row groups. | 56 | // Frame the selection and separate numbered and named row groups. | ||
| 57 | _, scene.Separators = sc.mon.overviewRows() | 57 | _, scene.Separators = sc.mon.overviewRows() | ||
| 58 | scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...) | 58 | scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...) | ||
| 59 | + | } else if sc.mon.sw.shown { | |||
| 60 | + | // The switcher's cards: only the selection is framed. | |||
| 61 | + | scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2)) | |||
| 59 | } else { | 62 | } else { | ||
| 60 | // Only the focused output lights the focused window's lines. | 63 | // Only the focused output lights the focused window's lines. | ||
| 61 | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen) | 64 | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen) | ||
| @@ -89,7 +92,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||||
| 89 | } | 92 | } | ||
| 90 | continue | 93 | continue | ||
| 91 | } | 94 | } | ||
| 92 | - | if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open && hasRoom(view, p) { | 95 | + | if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.previewing() && hasRoom(view, p) { |
| 93 | drawable = true | 96 | drawable = true | ||
| 94 | if p.ID == c.pulse.id { | 97 | if p.ID == c.pulse.id { | ||
| 95 | sw.FocusEffect = pulse | 98 | sw.FocusEffect = pulse | ||
M internal/core/screens.go +4 -1
| @@ -66,7 +66,7 @@ func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil } | |||
| 66 | 66 | // rectsOwner is what the screen's rect motions belong to: its workspace on | |
| 67 | 67 | // screen, or nil in the overview, where the rows change under the cards. | |
| 68 | 68 | func (s *screen) rectsOwner() *Workspace { | |
| 69 | - | if s.mon.ov.open { | |
| 69 | + | if s.mon.previewing() { | |
| 70 | 70 | return nil | |
| 71 | 71 | } | |
| 72 | 72 | return s.mon.Current() | |
| @@ -334,6 +334,9 @@ func (c *Core) removeScreen(name string) { | |||
| 334 | 334 | if i < 0 { | |
| 335 | 335 | return | |
| 336 | 336 | } | |
| 337 | + | if c.switcher.sc == c.screens[i] { | |
| 338 | + | c.cancelSwitcher() | |
| 339 | + | } | |
| 337 | 340 | // A capture session's off-screen workspace of this output goes with it. | |
| 338 | 341 | if c.configures.cw.sc == c.screens[i] { | |
| 339 | 342 | c.configures.cw.reset() | |
| @@ -66,7 +66,7 @@ func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil } | |||||
| 66 | // rectsOwner is what the screen's rect motions belong to: its workspace on | 66 | // rectsOwner is what the screen's rect motions belong to: its workspace on | ||
| 67 | // screen, or nil in the overview, where the rows change under the cards. | 67 | // screen, or nil in the overview, where the rows change under the cards. | ||
| 68 | func (s *screen) rectsOwner() *Workspace { | 68 | func (s *screen) rectsOwner() *Workspace { | ||
| 69 | - | if s.mon.ov.open { | 69 | + | if s.mon.previewing() { |
| 70 | return nil | 70 | return nil | ||
| 71 | } | 71 | } | ||
| 72 | return s.mon.Current() | 72 | return s.mon.Current() | ||
| @@ -334,6 +334,9 @@ func (c *Core) removeScreen(name string) { | |||||
| 334 | if i < 0 { | 334 | if i < 0 { | ||
| 335 | return | 335 | return | ||
| 336 | } | 336 | } | ||
| 337 | + | if c.switcher.sc == c.screens[i] { | |||
| 338 | + | c.cancelSwitcher() | |||
| 339 | + | } | |||
| 337 | // A capture session's off-screen workspace of this output goes with it. | 340 | // A capture session's off-screen workspace of this output goes with it. | ||
| 338 | if c.configures.cw.sc == c.screens[i] { | 341 | if c.configures.cw.sc == c.screens[i] { | ||
| 339 | c.configures.cw.reset() | 342 | c.configures.cw.reset() | ||
M internal/core/security.go +4 -0
| @@ -46,6 +46,10 @@ func (c *Core) syncSecurity() bool { | |||
| 46 | 46 | c.gesture = clientGesture{} | |
| 47 | 47 | c.stopAnimations() | |
| 48 | 48 | c.stopPulse() | |
| 49 | + | c.cancelSwitcher() | |
| 50 | + | for _, sc := range c.screens { | |
| 51 | + | sc.mon.sw = switcherState{} | |
| 52 | + | } | |
| 49 | 53 | if state.Protected { | |
| 50 | 54 | c.dropCaptureSessions() | |
| 51 | 55 | } | |
| @@ -46,6 +46,10 @@ func (c *Core) syncSecurity() bool { | |||||
| 46 | c.gesture = clientGesture{} | 46 | c.gesture = clientGesture{} | ||
| 47 | c.stopAnimations() | 47 | c.stopAnimations() | ||
| 48 | c.stopPulse() | 48 | c.stopPulse() | ||
| 49 | + | c.cancelSwitcher() | |||
| 50 | + | for _, sc := range c.screens { | |||
| 51 | + | sc.mon.sw = switcherState{} | |||
| 52 | + | } | |||
| 49 | if state.Protected { | 53 | if state.Protected { | ||
| 50 | c.dropCaptureSessions() | 54 | c.dropCaptureSessions() | ||
| 51 | } | 55 | } | ||
M internal/core/switcher.go +280 -0
| @@ -1,7 +1,10 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "math" | |
| 4 | 6 | "slices" | |
| 7 | + | "time" | |
| 5 | 8 | ) | |
| 6 | 9 | ||
| 7 | 10 | // The column switcher is an Alt+Tab for the tiled columns of the workspace on | |
| @@ -12,6 +15,10 @@ import ( | |||
| 12 | 15 | // switcherRecentMax caps a workspace's recent list. | |
| 13 | 16 | const switcherRecentMax = 64 | |
| 14 | 17 | ||
| 18 | + | // switcherDelay is how long the command key must stay held before the cards | |
| 19 | + | // show: a quick tap swaps at once and flashes nothing. | |
| 20 | + | const switcherDelay = 150 * time.Millisecond | |
| 21 | + | ||
| 15 | 22 | // switcherState is the switcher of a monitor. A window stands for each | |
| 16 | 23 | // column in order, the focused column first. Nothing moves until the commit; | |
| 17 | 24 | // shown is set once the cards are drawn. | |
| @@ -77,3 +84,276 @@ func (w *Workspace) switchOrder() []WindowID { | |||
| 77 | 84 | } | |
| 78 | 85 | return order | |
| 79 | 86 | } | |
| 87 | + | ||
| 88 | + | // remove drops a closed window from the open switcher: its card goes, the | |
| 89 | + | // selection stays on the same card (or the one that took its place). With | |
| 90 | + | // fewer than two columns left there is nothing to switch. | |
| 91 | + | func (s *switcherState) remove(id WindowID) { | |
| 92 | + | if !s.open { | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | i := slices.Index(s.order, id) | |
| 96 | + | if i < 0 { | |
| 97 | + | return | |
| 98 | + | } | |
| 99 | + | s.order = slices.Delete(s.order, i, i+1) | |
| 100 | + | if i < s.at { | |
| 101 | + | s.at-- | |
| 102 | + | } | |
| 103 | + | s.at = max(min(s.at, len(s.order)-1), 0) | |
| 104 | + | if len(s.order) < 2 { | |
| 105 | + | *s = switcherState{} | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // commitSwitcher closes the switcher and focuses the selected column. The | |
| 110 | + | // column left hands its maximization to the target. It does nothing when the | |
| 111 | + | // workspace is not on screen any more, the window went, or the selection is | |
| 112 | + | // the column already focused. | |
| 113 | + | func (m *Monitor) commitSwitcher() { | |
| 114 | + | s := m.sw | |
| 115 | + | m.sw = switcherState{} | |
| 116 | + | if !s.open || s.ws == nil || s.at < 0 || s.at >= len(s.order) { | |
| 117 | + | return | |
| 118 | + | } | |
| 119 | + | id, w := s.order[s.at], s.ws | |
| 120 | + | if w != m.Current() { | |
| 121 | + | return | |
| 122 | + | } | |
| 123 | + | i := w.columnOf(id) | |
| 124 | + | if i < 0 || i == w.Focus { | |
| 125 | + | return | |
| 126 | + | } | |
| 127 | + | if w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth { | |
| 128 | + | w.transferMaximization(i) | |
| 129 | + | } | |
| 130 | + | // Activate leaves another window's fullscreen and handles dialogs. | |
| 131 | + | w.Activate(id) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // switchDir is the direction of a switch action, 0 for any other action. | |
| 135 | + | func switchDir(a Action) int { | |
| 136 | + | switch a { | |
| 137 | + | case ActionSwitchColumnNext: | |
| 138 | + | return 1 | |
| 139 | + | case ActionSwitchColumnPrev: | |
| 140 | + | return -1 | |
| 141 | + | } | |
| 142 | + | return 0 | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // switching reports whether a switcher is open. | |
| 146 | + | func (c *Core) switching() bool { | |
| 147 | + | return c.switcher.sc != nil && c.switcher.sc.mon.sw.open | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func (c *Core) stopSwitcherTimer() { | |
| 151 | + | if c.switcher.timerStop != nil { | |
| 152 | + | c.switcher.timerStop() | |
| 153 | + | } | |
| 154 | + | c.switcher.timerC, c.switcher.timerStop = nil, nil | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // cancelSwitcher closes the switcher with the focus unchanged. It only | |
| 158 | + | // resets state: callers publish. | |
| 159 | + | func (c *Core) cancelSwitcher() { | |
| 160 | + | c.stopSwitcherTimer() | |
| 161 | + | if sc := c.switcher.sc; sc != nil { | |
| 162 | + | sc.mon.sw = switcherState{} | |
| 163 | + | } | |
| 164 | + | c.switcher.sc = nil | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // commitSwitcher commits the switcher on its own screen and ends it. | |
| 168 | + | func (c *Core) commitSwitcher() { | |
| 169 | + | c.stopSwitcherTimer() | |
| 170 | + | if sc := c.switcher.sc; sc != nil { | |
| 171 | + | sc.mon.commitSwitcher() | |
| 172 | + | } | |
| 173 | + | c.switcher.sc = nil | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // switchStep steps the switcher by dir (+1 next, -1 previous), opening it | |
| 177 | + | // on the focused screen first. With the command key held the cards show after | |
| 178 | + | // switcherDelay and the commit waits for the release; otherwise it commits | |
| 179 | + | // at once. | |
| 180 | + | func (c *Core) switchStep(dir int) { | |
| 181 | + | sc := c.cur() | |
| 182 | + | if c.switching() && c.switcher.sc != sc { | |
| 183 | + | // The focus moved to another output: start over there. | |
| 184 | + | c.cancelSwitcher() | |
| 185 | + | } | |
| 186 | + | if sc.mon.ov.open { | |
| 187 | + | return | |
| 188 | + | } | |
| 189 | + | m := sc.mon | |
| 190 | + | opened := false | |
| 191 | + | if !c.switching() { | |
| 192 | + | c.cancelSwitcher() | |
| 193 | + | w := m.Current() | |
| 194 | + | order := w.switchOrder() | |
| 195 | + | if order == nil { | |
| 196 | + | return | |
| 197 | + | } | |
| 198 | + | sizes := make(map[WindowID]Rect, len(order)) | |
| 199 | + | for _, id := range order { | |
| 200 | + | if v, ok := c.configures.sent[id]; ok && v.Width > 0 && v.Height > 0 { | |
| 201 | + | sizes[id] = Rect{W: v.Width, H: v.Height} | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | at := 1 | |
| 205 | + | if dir < 0 { | |
| 206 | + | at = len(order) - 1 | |
| 207 | + | } | |
| 208 | + | m.sw = switcherState{open: true, ws: w, order: order, at: at, sizes: sizes} | |
| 209 | + | c.switcher.sc = sc | |
| 210 | + | opened = true | |
| 211 | + | } else { | |
| 212 | + | n := len(m.sw.order) | |
| 213 | + | m.sw.at = (m.sw.at + dir + n) % n | |
| 214 | + | } | |
| 215 | + | if held := c.cmdMod != 0 && c.mods&c.cmdMod != 0; !held { | |
| 216 | + | c.commitSwitcher() | |
| 217 | + | return | |
| 218 | + | } | |
| 219 | + | if opened { | |
| 220 | + | c.switcher.timerC, c.switcher.timerStop = newTimer(c.opts.Clock, switcherDelay) | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // switchBind runs a switch-column bind. | |
| 225 | + | func (c *Core) switchBind(ctx context.Context, dir int) error { | |
| 226 | + | now := c.now() | |
| 227 | + | shots := c.snapshot(now) | |
| 228 | + | c.keyboard.takeBack() // a bind acts on the windows | |
| 229 | + | c.switchStep(dir) | |
| 230 | + | c.transition(shots, now) | |
| 231 | + | if err := c.publish(ctx); err != nil { | |
| 232 | + | return err | |
| 233 | + | } | |
| 234 | + | return c.rehit(ctx) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // releaseSwitcher commits the selection once the command key is up. | |
| 238 | + | func (c *Core) releaseSwitcher(ctx context.Context) error { | |
| 239 | + | now := c.now() | |
| 240 | + | shots := c.snapshot(now) | |
| 241 | + | c.commitSwitcher() | |
| 242 | + | c.transition(shots, now) | |
| 243 | + | if err := c.publish(ctx); err != nil { | |
| 244 | + | return err | |
| 245 | + | } | |
| 246 | + | return c.rehit(ctx) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // abortSwitcher cancels the switcher and publishes the workspace again. | |
| 250 | + | func (c *Core) abortSwitcher(ctx context.Context) error { | |
| 251 | + | now := c.now() | |
| 252 | + | shots := c.snapshot(now) | |
| 253 | + | c.cancelSwitcher() | |
| 254 | + | c.transition(shots, now) | |
| 255 | + | if err := c.publish(ctx); err != nil { | |
| 256 | + | return err | |
| 257 | + | } | |
| 258 | + | return c.rehit(ctx) | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // switcherTick runs when the delay passed with the command key held: the | |
| 262 | + | // cards show. It reports whether the scene changed. | |
| 263 | + | func (c *Core) switcherTick() bool { | |
| 264 | + | c.switcher.timerC, c.switcher.timerStop = nil, nil | |
| 265 | + | sc := c.switcher.sc | |
| 266 | + | if !c.switching() { | |
| 267 | + | c.switcher.sc = nil | |
| 268 | + | return false | |
| 269 | + | } | |
| 270 | + | m := sc.mon | |
| 271 | + | if !c.hasScreen(sc) || c.security.Protected || m.sw.ws != m.Current() { | |
| 272 | + | c.cancelSwitcher() | |
| 273 | + | return true | |
| 274 | + | } | |
| 275 | + | if m.sw.shown { | |
| 276 | + | return false | |
| 277 | + | } | |
| 278 | + | now := c.now() | |
| 279 | + | shots := c.snapshot(now) | |
| 280 | + | m.sw.shown = true | |
| 281 | + | c.transition(shots, now) | |
| 282 | + | return true | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // switcherClick takes a press while the switcher is open. Before the cards | |
| 286 | + | // show it cancels and lets the press through (handled false). On the cards | |
| 287 | + | // a press on one commits it, anywhere else cancels; the press and its | |
| 288 | + | // release are consumed either way. | |
| 289 | + | func (c *Core) switcherClick(ctx context.Context, button uint32) (handled bool, err error) { | |
| 290 | + | sc := c.switcher.sc | |
| 291 | + | if !c.switching() { | |
| 292 | + | return false, nil | |
| 293 | + | } | |
| 294 | + | if !sc.mon.sw.shown { | |
| 295 | + | c.cancelSwitcher() | |
| 296 | + | return false, nil | |
| 297 | + | } | |
| 298 | + | now := c.now() | |
| 299 | + | shots := c.snapshot(now) | |
| 300 | + | id := overviewIn(sc.shownLayout(), c.cursorX-float64(sc.x), c.cursorY-float64(sc.y)) | |
| 301 | + | if i := slices.Index(sc.mon.sw.order, id); id != 0 && i >= 0 { | |
| 302 | + | sc.mon.sw.at = i | |
| 303 | + | c.commitSwitcher() | |
| 304 | + | } else { | |
| 305 | + | c.cancelSwitcher() | |
| 306 | + | } | |
| 307 | + | c.buttons[button], c.swallow[button] = true, true | |
| 308 | + | c.transition(shots, now) | |
| 309 | + | if err := c.publish(ctx); err != nil { | |
| 310 | + | return true, err | |
| 311 | + | } | |
| 312 | + | return true, c.rehit(ctx) | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | // switcherLayout lays out the shown switcher: one card per column in | |
| 316 | + | // switchOrder, equal boxes in a row centered in the overview area, every | |
| 317 | + | // window scaled into its box (never up) and the selected card Focused. | |
| 318 | + | // Like the overview, the selected card gets Focused (so its configure is | |
| 319 | + | // Activated) while the keyboard stays on the real focus. Every other window | |
| 320 | + | // is hidden. | |
| 321 | + | func (m *Monitor) switcherLayout(dst []Placement) []Placement { | |
| 322 | + | sw := &m.sw | |
| 323 | + | w := sw.ws | |
| 324 | + | area := w.overviewArea() | |
| 325 | + | n := len(sw.order) | |
| 326 | + | for ws := range m.all() { | |
| 327 | + | start := len(dst) | |
| 328 | + | dst = ws.appendHidden(dst, true) | |
| 329 | + | kept := slices.DeleteFunc(dst[start:], func(p Placement) bool { return slices.Contains(sw.order, p.ID) }) | |
| 330 | + | dst = dst[:start+len(kept)] | |
| 331 | + | } | |
| 332 | + | if n == 0 { | |
| 333 | + | return dst | |
| 334 | + | } | |
| 335 | + | gap := max(1, area.W*2/100) | |
| 336 | + | boxW := max(min(area.W/5, (area.W*9/10-(n-1)*gap)/n), 1) | |
| 337 | + | boxH := max(boxW*area.H/max(area.W, 1), 1) | |
| 338 | + | x0 := area.X + (area.W-(n*boxW+(n-1)*gap))/2 | |
| 339 | + | y0 := area.Y + (area.H-boxH)/2 | |
| 340 | + | for k, id := range sw.order { | |
| 341 | + | s := sw.sizes[id] | |
| 342 | + | if s.W <= 0 || s.H <= 0 { | |
| 343 | + | s = Rect{W: w.Usable.W, H: w.Usable.H} | |
| 344 | + | } | |
| 345 | + | z := 1.0 | |
| 346 | + | if s.W > 0 && s.H > 0 { | |
| 347 | + | z = math.Min(1, math.Min(float64(boxW)/float64(s.W), float64(boxH)/float64(s.H))) | |
| 348 | + | } | |
| 349 | + | rw, rh := int(math.Round(float64(s.W)*z)), int(math.Round(float64(s.H)*z)) | |
| 350 | + | bx := x0 + k*(boxW+gap) | |
| 351 | + | dst = append(dst, Placement{ | |
| 352 | + | ID: id, | |
| 353 | + | Rect: Rect{X: bx + (boxW-rw)/2, Y: y0 + (boxH-rh)/2, W: rw, H: rh}, | |
| 354 | + | Preview: z, | |
| 355 | + | Focused: k == sw.at, | |
| 356 | + | }) | |
| 357 | + | } | |
| 358 | + | return dst | |
| 359 | + | } | |
| @@ -1,7 +1,10 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | "math" | |||
| 4 | "slices" | 6 | "slices" | ||
| 7 | + | "time" | |||
| 5 | ) | 8 | ) | ||
| 6 | 9 | ||||
| 7 | // The column switcher is an Alt+Tab for the tiled columns of the workspace on | 10 | // The column switcher is an Alt+Tab for the tiled columns of the workspace on | ||
| @@ -12,6 +15,10 @@ import ( | |||||
| 12 | // switcherRecentMax caps a workspace's recent list. | 15 | // switcherRecentMax caps a workspace's recent list. | ||
| 13 | const switcherRecentMax = 64 | 16 | const switcherRecentMax = 64 | ||
| 14 | 17 | ||||
| 18 | + | // switcherDelay is how long the command key must stay held before the cards | |||
| 19 | + | // show: a quick tap swaps at once and flashes nothing. | |||
| 20 | + | const switcherDelay = 150 * time.Millisecond | |||
| 21 | + | ||||
| 15 | // switcherState is the switcher of a monitor. A window stands for each | 22 | // switcherState is the switcher of a monitor. A window stands for each | ||
| 16 | // column in order, the focused column first. Nothing moves until the commit; | 23 | // column in order, the focused column first. Nothing moves until the commit; | ||
| 17 | // shown is set once the cards are drawn. | 24 | // shown is set once the cards are drawn. | ||
| @@ -77,3 +84,276 @@ func (w *Workspace) switchOrder() []WindowID { | |||||
| 77 | } | 84 | } | ||
| 78 | return order | 85 | return order | ||
| 79 | } | 86 | } | ||
| 87 | + | ||||
| 88 | + | // remove drops a closed window from the open switcher: its card goes, the | |||
| 89 | + | // selection stays on the same card (or the one that took its place). With | |||
| 90 | + | // fewer than two columns left there is nothing to switch. | |||
| 91 | + | func (s *switcherState) remove(id WindowID) { | |||
| 92 | + | if !s.open { | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | i := slices.Index(s.order, id) | |||
| 96 | + | if i < 0 { | |||
| 97 | + | return | |||
| 98 | + | } | |||
| 99 | + | s.order = slices.Delete(s.order, i, i+1) | |||
| 100 | + | if i < s.at { | |||
| 101 | + | s.at-- | |||
| 102 | + | } | |||
| 103 | + | s.at = max(min(s.at, len(s.order)-1), 0) | |||
| 104 | + | if len(s.order) < 2 { | |||
| 105 | + | *s = switcherState{} | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // commitSwitcher closes the switcher and focuses the selected column. The | |||
| 110 | + | // column left hands its maximization to the target. It does nothing when the | |||
| 111 | + | // workspace is not on screen any more, the window went, or the selection is | |||
| 112 | + | // the column already focused. | |||
| 113 | + | func (m *Monitor) commitSwitcher() { | |||
| 114 | + | s := m.sw | |||
| 115 | + | m.sw = switcherState{} | |||
| 116 | + | if !s.open || s.ws == nil || s.at < 0 || s.at >= len(s.order) { | |||
| 117 | + | return | |||
| 118 | + | } | |||
| 119 | + | id, w := s.order[s.at], s.ws | |||
| 120 | + | if w != m.Current() { | |||
| 121 | + | return | |||
| 122 | + | } | |||
| 123 | + | i := w.columnOf(id) | |||
| 124 | + | if i < 0 || i == w.Focus { | |||
| 125 | + | return | |||
| 126 | + | } | |||
| 127 | + | if w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth { | |||
| 128 | + | w.transferMaximization(i) | |||
| 129 | + | } | |||
| 130 | + | // Activate leaves another window's fullscreen and handles dialogs. | |||
| 131 | + | w.Activate(id) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // switchDir is the direction of a switch action, 0 for any other action. | |||
| 135 | + | func switchDir(a Action) int { | |||
| 136 | + | switch a { | |||
| 137 | + | case ActionSwitchColumnNext: | |||
| 138 | + | return 1 | |||
| 139 | + | case ActionSwitchColumnPrev: | |||
| 140 | + | return -1 | |||
| 141 | + | } | |||
| 142 | + | return 0 | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // switching reports whether a switcher is open. | |||
| 146 | + | func (c *Core) switching() bool { | |||
| 147 | + | return c.switcher.sc != nil && c.switcher.sc.mon.sw.open | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func (c *Core) stopSwitcherTimer() { | |||
| 151 | + | if c.switcher.timerStop != nil { | |||
| 152 | + | c.switcher.timerStop() | |||
| 153 | + | } | |||
| 154 | + | c.switcher.timerC, c.switcher.timerStop = nil, nil | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // cancelSwitcher closes the switcher with the focus unchanged. It only | |||
| 158 | + | // resets state: callers publish. | |||
| 159 | + | func (c *Core) cancelSwitcher() { | |||
| 160 | + | c.stopSwitcherTimer() | |||
| 161 | + | if sc := c.switcher.sc; sc != nil { | |||
| 162 | + | sc.mon.sw = switcherState{} | |||
| 163 | + | } | |||
| 164 | + | c.switcher.sc = nil | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // commitSwitcher commits the switcher on its own screen and ends it. | |||
| 168 | + | func (c *Core) commitSwitcher() { | |||
| 169 | + | c.stopSwitcherTimer() | |||
| 170 | + | if sc := c.switcher.sc; sc != nil { | |||
| 171 | + | sc.mon.commitSwitcher() | |||
| 172 | + | } | |||
| 173 | + | c.switcher.sc = nil | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // switchStep steps the switcher by dir (+1 next, -1 previous), opening it | |||
| 177 | + | // on the focused screen first. With the command key held the cards show after | |||
| 178 | + | // switcherDelay and the commit waits for the release; otherwise it commits | |||
| 179 | + | // at once. | |||
| 180 | + | func (c *Core) switchStep(dir int) { | |||
| 181 | + | sc := c.cur() | |||
| 182 | + | if c.switching() && c.switcher.sc != sc { | |||
| 183 | + | // The focus moved to another output: start over there. | |||
| 184 | + | c.cancelSwitcher() | |||
| 185 | + | } | |||
| 186 | + | if sc.mon.ov.open { | |||
| 187 | + | return | |||
| 188 | + | } | |||
| 189 | + | m := sc.mon | |||
| 190 | + | opened := false | |||
| 191 | + | if !c.switching() { | |||
| 192 | + | c.cancelSwitcher() | |||
| 193 | + | w := m.Current() | |||
| 194 | + | order := w.switchOrder() | |||
| 195 | + | if order == nil { | |||
| 196 | + | return | |||
| 197 | + | } | |||
| 198 | + | sizes := make(map[WindowID]Rect, len(order)) | |||
| 199 | + | for _, id := range order { | |||
| 200 | + | if v, ok := c.configures.sent[id]; ok && v.Width > 0 && v.Height > 0 { | |||
| 201 | + | sizes[id] = Rect{W: v.Width, H: v.Height} | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | at := 1 | |||
| 205 | + | if dir < 0 { | |||
| 206 | + | at = len(order) - 1 | |||
| 207 | + | } | |||
| 208 | + | m.sw = switcherState{open: true, ws: w, order: order, at: at, sizes: sizes} | |||
| 209 | + | c.switcher.sc = sc | |||
| 210 | + | opened = true | |||
| 211 | + | } else { | |||
| 212 | + | n := len(m.sw.order) | |||
| 213 | + | m.sw.at = (m.sw.at + dir + n) % n | |||
| 214 | + | } | |||
| 215 | + | if held := c.cmdMod != 0 && c.mods&c.cmdMod != 0; !held { | |||
| 216 | + | c.commitSwitcher() | |||
| 217 | + | return | |||
| 218 | + | } | |||
| 219 | + | if opened { | |||
| 220 | + | c.switcher.timerC, c.switcher.timerStop = newTimer(c.opts.Clock, switcherDelay) | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // switchBind runs a switch-column bind. | |||
| 225 | + | func (c *Core) switchBind(ctx context.Context, dir int) error { | |||
| 226 | + | now := c.now() | |||
| 227 | + | shots := c.snapshot(now) | |||
| 228 | + | c.keyboard.takeBack() // a bind acts on the windows | |||
| 229 | + | c.switchStep(dir) | |||
| 230 | + | c.transition(shots, now) | |||
| 231 | + | if err := c.publish(ctx); err != nil { | |||
| 232 | + | return err | |||
| 233 | + | } | |||
| 234 | + | return c.rehit(ctx) | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | // releaseSwitcher commits the selection once the command key is up. | |||
| 238 | + | func (c *Core) releaseSwitcher(ctx context.Context) error { | |||
| 239 | + | now := c.now() | |||
| 240 | + | shots := c.snapshot(now) | |||
| 241 | + | c.commitSwitcher() | |||
| 242 | + | c.transition(shots, now) | |||
| 243 | + | if err := c.publish(ctx); err != nil { | |||
| 244 | + | return err | |||
| 245 | + | } | |||
| 246 | + | return c.rehit(ctx) | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // abortSwitcher cancels the switcher and publishes the workspace again. | |||
| 250 | + | func (c *Core) abortSwitcher(ctx context.Context) error { | |||
| 251 | + | now := c.now() | |||
| 252 | + | shots := c.snapshot(now) | |||
| 253 | + | c.cancelSwitcher() | |||
| 254 | + | c.transition(shots, now) | |||
| 255 | + | if err := c.publish(ctx); err != nil { | |||
| 256 | + | return err | |||
| 257 | + | } | |||
| 258 | + | return c.rehit(ctx) | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | // switcherTick runs when the delay passed with the command key held: the | |||
| 262 | + | // cards show. It reports whether the scene changed. | |||
| 263 | + | func (c *Core) switcherTick() bool { | |||
| 264 | + | c.switcher.timerC, c.switcher.timerStop = nil, nil | |||
| 265 | + | sc := c.switcher.sc | |||
| 266 | + | if !c.switching() { | |||
| 267 | + | c.switcher.sc = nil | |||
| 268 | + | return false | |||
| 269 | + | } | |||
| 270 | + | m := sc.mon | |||
| 271 | + | if !c.hasScreen(sc) || c.security.Protected || m.sw.ws != m.Current() { | |||
| 272 | + | c.cancelSwitcher() | |||
| 273 | + | return true | |||
| 274 | + | } | |||
| 275 | + | if m.sw.shown { | |||
| 276 | + | return false | |||
| 277 | + | } | |||
| 278 | + | now := c.now() | |||
| 279 | + | shots := c.snapshot(now) | |||
| 280 | + | m.sw.shown = true | |||
| 281 | + | c.transition(shots, now) | |||
| 282 | + | return true | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | // switcherClick takes a press while the switcher is open. Before the cards | |||
| 286 | + | // show it cancels and lets the press through (handled false). On the cards | |||
| 287 | + | // a press on one commits it, anywhere else cancels; the press and its | |||
| 288 | + | // release are consumed either way. | |||
| 289 | + | func (c *Core) switcherClick(ctx context.Context, button uint32) (handled bool, err error) { | |||
| 290 | + | sc := c.switcher.sc | |||
| 291 | + | if !c.switching() { | |||
| 292 | + | return false, nil | |||
| 293 | + | } | |||
| 294 | + | if !sc.mon.sw.shown { | |||
| 295 | + | c.cancelSwitcher() | |||
| 296 | + | return false, nil | |||
| 297 | + | } | |||
| 298 | + | now := c.now() | |||
| 299 | + | shots := c.snapshot(now) | |||
| 300 | + | id := overviewIn(sc.shownLayout(), c.cursorX-float64(sc.x), c.cursorY-float64(sc.y)) | |||
| 301 | + | if i := slices.Index(sc.mon.sw.order, id); id != 0 && i >= 0 { | |||
| 302 | + | sc.mon.sw.at = i | |||
| 303 | + | c.commitSwitcher() | |||
| 304 | + | } else { | |||
| 305 | + | c.cancelSwitcher() | |||
| 306 | + | } | |||
| 307 | + | c.buttons[button], c.swallow[button] = true, true | |||
| 308 | + | c.transition(shots, now) | |||
| 309 | + | if err := c.publish(ctx); err != nil { | |||
| 310 | + | return true, err | |||
| 311 | + | } | |||
| 312 | + | return true, c.rehit(ctx) | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | // switcherLayout lays out the shown switcher: one card per column in | |||
| 316 | + | // switchOrder, equal boxes in a row centered in the overview area, every | |||
| 317 | + | // window scaled into its box (never up) and the selected card Focused. | |||
| 318 | + | // Like the overview, the selected card gets Focused (so its configure is | |||
| 319 | + | // Activated) while the keyboard stays on the real focus. Every other window | |||
| 320 | + | // is hidden. | |||
| 321 | + | func (m *Monitor) switcherLayout(dst []Placement) []Placement { | |||
| 322 | + | sw := &m.sw | |||
| 323 | + | w := sw.ws | |||
| 324 | + | area := w.overviewArea() | |||
| 325 | + | n := len(sw.order) | |||
| 326 | + | for ws := range m.all() { | |||
| 327 | + | start := len(dst) | |||
| 328 | + | dst = ws.appendHidden(dst, true) | |||
| 329 | + | kept := slices.DeleteFunc(dst[start:], func(p Placement) bool { return slices.Contains(sw.order, p.ID) }) | |||
| 330 | + | dst = dst[:start+len(kept)] | |||
| 331 | + | } | |||
| 332 | + | if n == 0 { | |||
| 333 | + | return dst | |||
| 334 | + | } | |||
| 335 | + | gap := max(1, area.W*2/100) | |||
| 336 | + | boxW := max(min(area.W/5, (area.W*9/10-(n-1)*gap)/n), 1) | |||
| 337 | + | boxH := max(boxW*area.H/max(area.W, 1), 1) | |||
| 338 | + | x0 := area.X + (area.W-(n*boxW+(n-1)*gap))/2 | |||
| 339 | + | y0 := area.Y + (area.H-boxH)/2 | |||
| 340 | + | for k, id := range sw.order { | |||
| 341 | + | s := sw.sizes[id] | |||
| 342 | + | if s.W <= 0 || s.H <= 0 { | |||
| 343 | + | s = Rect{W: w.Usable.W, H: w.Usable.H} | |||
| 344 | + | } | |||
| 345 | + | z := 1.0 | |||
| 346 | + | if s.W > 0 && s.H > 0 { | |||
| 347 | + | z = math.Min(1, math.Min(float64(boxW)/float64(s.W), float64(boxH)/float64(s.H))) | |||
| 348 | + | } | |||
| 349 | + | rw, rh := int(math.Round(float64(s.W)*z)), int(math.Round(float64(s.H)*z)) | |||
| 350 | + | bx := x0 + k*(boxW+gap) | |||
| 351 | + | dst = append(dst, Placement{ | |||
| 352 | + | ID: id, | |||
| 353 | + | Rect: Rect{X: bx + (boxW-rw)/2, Y: y0 + (boxH-rh)/2, W: rw, H: rh}, | |||
| 354 | + | Preview: z, | |||
| 355 | + | Focused: k == sw.at, | |||
| 356 | + | }) | |||
| 357 | + | } | |||
| 358 | + | return dst | |||
| 359 | + | } | |||
M internal/core/switcher_test.go +749 -0
| @@ -1,8 +1,13 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "slices" | |
| 5 | 6 | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |
| 6 | 11 | ) | |
| 7 | 12 | ||
| 8 | 13 | // threeColumns is a workspace of three one-window columns, windows 1..3. | |
| @@ -127,3 +132,747 @@ func TestMRUNoteFocusDoesNotAllocateWhenSettled(t *testing.T) { | |||
| 127 | 132 | t.Fatalf("noteFocus allocates %v", n) | |
| 128 | 133 | } | |
| 129 | 134 | } | |
| 135 | + | ||
| 136 | + | // switcherBinds are the default binds of the switcher. | |
| 137 | + | var switcherBinds = map[string]string{ | |
| 138 | + | "Cmd+Tab": string(ActionSwitchColumnNext), | |
| 139 | + | "Cmd+Shift+Tab": string(ActionSwitchColumnPrev), | |
| 140 | + | "Cmd+f": string(ActionToggleFullscreen), | |
| 141 | + | "F9": string(ActionSwitchColumnNext), | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | const ( | |
| 145 | + | keyTab = 15 | |
| 146 | + | keySuper = 133 | |
| 147 | + | ) | |
| 148 | + | ||
| 149 | + | // switcherCore is indicatorCore with the switcher binds, up to three | |
| 150 | + | // columns on screen and n one-window columns (windows 1..n, n focused), | |
| 151 | + | // published once so the configures carry the windows' sizes. | |
| 152 | + | func switcherCore(t *testing.T, n int) (*Core, *indicatorClock) { | |
| 153 | + | t.Helper() | |
| 154 | + | commands := make(chan ports.ClientCommand, 64) | |
| 155 | + | c, ic := indicatorCoreWith(t, commands) | |
| 156 | + | switcherCommands[c] = commands | |
| 157 | + | t.Cleanup(func() { delete(switcherCommands, c) }) | |
| 158 | + | cfg := c.cfg | |
| 159 | + | cfg.Layout.MaxColumns = 3 | |
| 160 | + | cfg.Binds = switcherBinds | |
| 161 | + | if err := c.apply(cfg); err != nil { | |
| 162 | + | t.Fatal(err) | |
| 163 | + | } | |
| 164 | + | for id := WindowID(1); id <= WindowID(n); id++ { | |
| 165 | + | c.cur().mon.AddWindow(id) | |
| 166 | + | } | |
| 167 | + | indicatorScene(t, c) | |
| 168 | + | return c, ic | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func tab(mods ports.Mods, pressed bool) ports.KeyEvent { | |
| 172 | + | return ports.KeyEvent{Keysym: "Tab", Keycode: keyTab, Mods: mods, Pressed: pressed} | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func shiftTab(pressed bool) ports.KeyEvent { | |
| 176 | + | return ports.KeyEvent{Keysym: "ISO_Left_Tab", Base: "Tab", Keycode: keyTab, Mods: ports.ModSuper | ports.ModShift, Pressed: pressed} | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func superKey(mods ports.Mods, pressed bool) ports.KeyEvent { | |
| 180 | + | return ports.KeyEvent{Keysym: "Super_L", Keycode: keySuper, Mods: mods, Pressed: pressed} | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func sendKeys(t *testing.T, c *Core, keys ...ports.KeyEvent) { | |
| 184 | + | t.Helper() | |
| 185 | + | for _, k := range keys { | |
| 186 | + | if err := c.keyEvent(context.Background(), k); err != nil { | |
| 187 | + | t.Fatal(err) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // lastScene returns the latest published scene. | |
| 193 | + | func lastScene(t *testing.T, c *Core) ports.Scene { | |
| 194 | + | t.Helper() | |
| 195 | + | var s []ports.Scene | |
| 196 | + | for len(c.ch.Scenes) > 0 { | |
| 197 | + | s = <-c.ch.Scenes | |
| 198 | + | } | |
| 199 | + | if s == nil { | |
| 200 | + | t.Fatal("no scene published") | |
| 201 | + | } | |
| 202 | + | return s[0] | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func switcherFocus(c *Core) WindowID { | |
| 206 | + | id, _ := c.cur().mon.Focused() | |
| 207 | + | return id | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // switcherCommands is the receiving end of each switcherCore's commands. | |
| 211 | + | var switcherCommands = map[*Core]chan ports.ClientCommand{} | |
| 212 | + | ||
| 213 | + | // forwarded drains the commands and returns the forwarded keys. | |
| 214 | + | func forwarded(c *Core) []ports.ForwardKey { | |
| 215 | + | var out []ports.ForwardKey | |
| 216 | + | ch := switcherCommands[c] | |
| 217 | + | for len(ch) > 0 { | |
| 218 | + | if v, ok := (<-ch).(ports.ForwardKey); ok { | |
| 219 | + | out = append(out, v) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | return out | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func scenePreviews(s ports.Scene) []ports.SceneWindow { | |
| 226 | + | var out []ports.SceneWindow | |
| 227 | + | for _, w := range s.Windows { | |
| 228 | + | if w.Preview > 0 { | |
| 229 | + | out = append(out, w) | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | return out | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // --- P2.1: commit --- | |
| 236 | + | ||
| 237 | + | func switcherMonitor(overflow Overflow, n int) (*Monitor, *Workspace) { | |
| 238 | + | m := NewMonitor() | |
| 239 | + | m.SetOutput(300, 200) | |
| 240 | + | m.SetMaxColumns(3) | |
| 241 | + | m.SetOverflow(overflow) | |
| 242 | + | for id := WindowID(1); id <= WindowID(n); id++ { | |
| 243 | + | m.AddWindow(id) | |
| 244 | + | } | |
| 245 | + | return m, m.Current() | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func openSwitcher(m *Monitor, at int) { | |
| 249 | + | w := m.Current() | |
| 250 | + | m.sw = switcherState{open: true, ws: w, order: w.switchOrder(), at: at} | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func TestSwitchCommitFocusesSelectedColumn(t *testing.T) { | |
| 254 | + | m, w := switcherMonitor(OverflowScroll, 3) | |
| 255 | + | openSwitcher(m, 1) | |
| 256 | + | want := m.sw.order[1] | |
| 257 | + | m.commitSwitcher() | |
| 258 | + | if id, _ := w.Focused(); id != want || m.sw.open { | |
| 259 | + | t.Fatalf("focus %d (want %d), switcher open %v", id, want, m.sw.open) | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func TestSwitchCommitTransfersMaximization(t *testing.T) { | |
| 264 | + | for _, overflow := range []Overflow{OverflowCascade, OverflowFixed, OverflowScroll} { | |
| 265 | + | t.Run(string(overflow), func(t *testing.T) { | |
| 266 | + | m, w := switcherMonitor(overflow, 2) | |
| 267 | + | a, b := w.Focus, 1-w.Focus | |
| 268 | + | w.FocusID(w.Columns[a].Windows[0]) | |
| 269 | + | w.ToggleFullWidth() | |
| 270 | + | w.noteFocus() | |
| 271 | + | if !w.Columns[a].FullWidth { | |
| 272 | + | t.Fatal("setup: source not maximized") | |
| 273 | + | } | |
| 274 | + | openSwitcher(m, 1) | |
| 275 | + | m.commitSwitcher() | |
| 276 | + | if w.Focus != b || !w.Columns[b].FullWidth || w.Columns[a].FullWidth { | |
| 277 | + | t.Fatalf("focus %d, FullWidth source %v target %v", w.Focus, w.Columns[a].FullWidth, w.Columns[b].FullWidth) | |
| 278 | + | } | |
| 279 | + | if w.policy().equalCells && !w.hiddenByMaximized(a) { | |
| 280 | + | t.Fatal("source not hidden by the maximized target") | |
| 281 | + | } | |
| 282 | + | }) | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func TestSwitchCommitLeavesPinnedFullscreen(t *testing.T) { | |
| 287 | + | m, w := switcherMonitor(OverflowFixed, 2) | |
| 288 | + | src := w.Columns[w.Focus].Windows[0] | |
| 289 | + | w.SetFullscreen(src, true) | |
| 290 | + | if w.fullscreen != src { | |
| 291 | + | t.Fatal("setup: no fullscreen") | |
| 292 | + | } | |
| 293 | + | openSwitcher(m, 1) | |
| 294 | + | target := m.sw.order[1] | |
| 295 | + | m.commitSwitcher() | |
| 296 | + | if w.fullscreen != 0 { | |
| 297 | + | t.Fatalf("fullscreen %d kept", w.fullscreen) | |
| 298 | + | } | |
| 299 | + | if id, _ := w.Focused(); id != target { | |
| 300 | + | t.Fatalf("focus %d, want %d", id, target) | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func TestSwitchCommitIgnoresGoneWindowAndOtherWorkspace(t *testing.T) { | |
| 305 | + | m, w := switcherMonitor(OverflowScroll, 3) | |
| 306 | + | before, _ := w.Focused() | |
| 307 | + | openSwitcher(m, 1) | |
| 308 | + | m.sw.order[1] = 99 // not in the workspace any more | |
| 309 | + | m.commitSwitcher() | |
| 310 | + | if id, _ := w.Focused(); id != before || m.sw.open { | |
| 311 | + | t.Fatalf("focus %d, want %d; open %v", id, before, m.sw.open) | |
| 312 | + | } | |
| 313 | + | openSwitcher(m, 1) | |
| 314 | + | m.sw.ws = &Workspace{} | |
| 315 | + | m.commitSwitcher() | |
| 316 | + | if id, _ := w.Focused(); id != before || m.sw.open { | |
| 317 | + | t.Fatalf("focus %d for a foreign workspace, want %d", id, before) | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | // --- P2.2: actions and step --- | |
| 322 | + | ||
| 323 | + | func TestSwitchStepOpensOnPreviousColumnAndCycles(t *testing.T) { | |
| 324 | + | c, ic := switcherCore(t, 3) | |
| 325 | + | c.mods = ports.ModSuper | |
| 326 | + | before := switcherFocus(c) | |
| 327 | + | if err := c.runBind(context.Background(), ActionSwitchColumnNext); err != nil { | |
| 328 | + | t.Fatal(err) | |
| 329 | + | } | |
| 330 | + | sw := &c.cur().mon.sw | |
| 331 | + | if !sw.open || sw.at != 1 || switcherFocus(c) != before { | |
| 332 | + | t.Fatalf("open %v at %d focus %d (was %d)", sw.open, sw.at, switcherFocus(c), before) | |
| 333 | + | } | |
| 334 | + | if len(ic.timers) != 1 || ic.timers[0] != switcherDelay { | |
| 335 | + | t.Fatalf("timers %v, want one of %v", ic.timers, switcherDelay) | |
| 336 | + | } | |
| 337 | + | c.runBind(context.Background(), ActionSwitchColumnNext) | |
| 338 | + | if sw.at != 2 { | |
| 339 | + | t.Fatalf("at %d", sw.at) | |
| 340 | + | } | |
| 341 | + | c.runBind(context.Background(), ActionSwitchColumnNext) | |
| 342 | + | if sw.at != 0 { | |
| 343 | + | t.Fatalf("at %d, want wrap to 0", sw.at) | |
| 344 | + | } | |
| 345 | + | c.runBind(context.Background(), ActionSwitchColumnPrev) | |
| 346 | + | if sw.at != 2 { | |
| 347 | + | t.Fatalf("at %d, want wrap to 2", sw.at) | |
| 348 | + | } | |
| 349 | + | c.runBind(context.Background(), ActionSwitchColumnPrev) | |
| 350 | + | if sw.at != 1 { | |
| 351 | + | t.Fatalf("at %d", sw.at) | |
| 352 | + | } | |
| 353 | + | if len(ic.timers) != 1 { | |
| 354 | + | t.Fatalf("timers %v: stepping must not re-arm", ic.timers) | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | func TestSwitchPrevOpensOnOldestColumn(t *testing.T) { | |
| 359 | + | c, _ := switcherCore(t, 3) | |
| 360 | + | c.mods = ports.ModSuper | |
| 361 | + | c.runBind(context.Background(), ActionSwitchColumnPrev) | |
| 362 | + | if sw := c.cur().mon.sw; !sw.open || sw.at != len(sw.order)-1 { | |
| 363 | + | t.Fatalf("at %d of %d", sw.at, len(sw.order)) | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func TestSwitchWithoutCmdCommitsAtOnce(t *testing.T) { | |
| 368 | + | c, ic := switcherCore(t, 3) | |
| 369 | + | c.mods = 0 | |
| 370 | + | start := switcherFocus(c) | |
| 371 | + | if err := c.runBind(context.Background(), ActionSwitchColumnNext); err != nil { | |
| 372 | + | t.Fatal(err) | |
| 373 | + | } | |
| 374 | + | if c.cur().mon.sw.open || c.switcher.timerC != nil || len(ic.timers) != 0 { | |
| 375 | + | t.Fatalf("open %v, timer %v %v", c.cur().mon.sw.open, c.switcher.timerC != nil, ic.timers) | |
| 376 | + | } | |
| 377 | + | if switcherFocus(c) == start { | |
| 378 | + | t.Fatal("focus did not move") | |
| 379 | + | } | |
| 380 | + | c.runBind(context.Background(), ActionSwitchColumnNext) | |
| 381 | + | if switcherFocus(c) != start { | |
| 382 | + | t.Fatalf("focus %d, want back on %d", switcherFocus(c), start) | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | func TestSwitchIgnoredWithOneColumnOrOverview(t *testing.T) { | |
| 387 | + | c, _ := switcherCore(t, 1) | |
| 388 | + | c.mods = ports.ModSuper | |
| 389 | + | c.runBind(context.Background(), ActionSwitchColumnNext) | |
| 390 | + | if c.cur().mon.sw.open || c.switcher.sc != nil { | |
| 391 | + | t.Fatal("switcher opened with one column") | |
| 392 | + | } | |
| 393 | + | c, _ = switcherCore(t, 3) | |
| 394 | + | c.cur().mon.ToggleOverview() | |
| 395 | + | before := switcherFocus(c) | |
| 396 | + | c.runBind(context.Background(), ActionSwitchColumnNext) | |
| 397 | + | if c.cur().mon.sw.open || c.switcher.sc != nil || switcherFocus(c) != before { | |
| 398 | + | t.Fatal("switcher acted in the overview") | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | // --- P2.3: release commit and timer --- | |
| 403 | + | ||
| 404 | + | func TestSwitchQuickTapSwapsWithoutCards(t *testing.T) { | |
| 405 | + | c, _ := switcherCore(t, 3) | |
| 406 | + | start := switcherFocus(c) | |
| 407 | + | for len(c.ch.Scenes) > 0 { | |
| 408 | + | <-c.ch.Scenes | |
| 409 | + | } | |
| 410 | + | tap := func() { | |
| 411 | + | t.Helper() | |
| 412 | + | sendKeys(t, c, | |
| 413 | + | superKey(ports.ModSuper, true), | |
| 414 | + | tab(ports.ModSuper, true), | |
| 415 | + | tab(ports.ModSuper, false), | |
| 416 | + | superKey(0, false), | |
| 417 | + | ) | |
| 418 | + | for len(c.ch.Scenes) > 0 { | |
| 419 | + | if p := scenePreviews((<-c.ch.Scenes)[0]); len(p) > 0 { | |
| 420 | + | t.Fatalf("cards published during a quick tap: %+v", p) | |
| 421 | + | } | |
| 422 | + | } | |
| 423 | + | if c.cur().mon.sw.open || c.switcher.sc != nil || c.switcher.timerC != nil { | |
| 424 | + | t.Fatal("switcher left open") | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | tap() | |
| 428 | + | if switcherFocus(c) == start { | |
| 429 | + | t.Fatal("tap did not swap") | |
| 430 | + | } | |
| 431 | + | tap() | |
| 432 | + | if switcherFocus(c) != start { | |
| 433 | + | t.Fatalf("second tap focus %d, want back on %d", switcherFocus(c), start) | |
| 434 | + | } | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | func TestSwitchHoldCommitsOnReleaseAndForwardsIt(t *testing.T) { | |
| 438 | + | c, _ := switcherCore(t, 3) | |
| 439 | + | start := switcherFocus(c) | |
| 440 | + | order := c.cur().mon.Current().switchOrder() | |
| 441 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true), tab(ports.ModSuper, false)) | |
| 442 | + | if switcherFocus(c) != start || !c.cur().mon.sw.open { | |
| 443 | + | t.Fatal("focus moved or switcher closed before the release") | |
| 444 | + | } | |
| 445 | + | forwarded(c) | |
| 446 | + | sendKeys(t, c, superKey(0, false)) | |
| 447 | + | if switcherFocus(c) != order[1] || c.cur().mon.sw.open || c.switcher.timerC != nil { | |
| 448 | + | t.Fatalf("focus %d (want %d), open %v", switcherFocus(c), order[1], c.cur().mon.sw.open) | |
| 449 | + | } | |
| 450 | + | fk := forwarded(c) | |
| 451 | + | if len(fk) != 1 || fk[0].Key.Keysym != "Super_L" || fk[0].Key.Pressed || fk[0].ID != order[1] { | |
| 452 | + | t.Fatalf("release not forwarded to the new focus: %+v", fk) | |
| 453 | + | } | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | func TestSwitcherTickShowsCards(t *testing.T) { | |
| 457 | + | c, _ := switcherCore(t, 3) | |
| 458 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |
| 459 | + | if c.cur().mon.sw.shown { | |
| 460 | + | t.Fatal("shown before the delay") | |
| 461 | + | } | |
| 462 | + | if !c.switcherTick() || !c.cur().mon.sw.shown { | |
| 463 | + | t.Fatal("tick did not show the switcher") | |
| 464 | + | } | |
| 465 | + | if c.switcher.timerC != nil { | |
| 466 | + | t.Fatal("timer left") | |
| 467 | + | } | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | func TestSwitcherTickAfterCloseIsHarmless(t *testing.T) { | |
| 471 | + | c, _ := switcherCore(t, 3) | |
| 472 | + | c.mods = ports.ModSuper | |
| 473 | + | c.runBind(context.Background(), ActionSwitchColumnNext) | |
| 474 | + | c.cancelSwitcher() | |
| 475 | + | if c.switcherTick() { | |
| 476 | + | t.Fatal("tick on a closed switcher asks for a scene") | |
| 477 | + | } | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | // --- P3.1: keys and gestures --- | |
| 481 | + | ||
| 482 | + | func TestSwitchEscapeCancelsAndIsNotForwarded(t *testing.T) { | |
| 483 | + | c, _ := switcherCore(t, 3) | |
| 484 | + | start := switcherFocus(c) | |
| 485 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true), tab(ports.ModSuper, false)) | |
| 486 | + | forwarded(c) | |
| 487 | + | esc := ports.KeyEvent{Keysym: "Escape", Keycode: 1, Mods: ports.ModSuper, Pressed: true} | |
| 488 | + | sendKeys(t, c, esc) | |
| 489 | + | esc.Pressed = false | |
| 490 | + | sendKeys(t, c, esc, superKey(0, false)) | |
| 491 | + | if c.cur().mon.sw.open || c.switcher.sc != nil || switcherFocus(c) != start { | |
| 492 | + | t.Fatalf("open %v focus %d (want %d)", c.cur().mon.sw.open, switcherFocus(c), start) | |
| 493 | + | } | |
| 494 | + | for _, k := range forwarded(c) { | |
| 495 | + | if k.Key.Keysym == "Escape" { | |
| 496 | + | t.Fatalf("Escape forwarded: %+v", k) | |
| 497 | + | } | |
| 498 | + | } | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | func TestSwitchAnotherBindCancelsThenRuns(t *testing.T) { | |
| 502 | + | c, _ := switcherCore(t, 3) | |
| 503 | + | start := switcherFocus(c) | |
| 504 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |
| 505 | + | sendKeys(t, c, ports.KeyEvent{Keysym: "f", Keycode: 33, Mods: ports.ModSuper, Pressed: true}) | |
| 506 | + | if c.cur().mon.sw.open || c.switcher.sc != nil || c.switcher.timerC != nil { | |
| 507 | + | t.Fatal("switcher still open") | |
| 508 | + | } | |
| 509 | + | if switcherFocus(c) != start { | |
| 510 | + | t.Fatalf("focus %d moved", switcherFocus(c)) | |
| 511 | + | } | |
| 512 | + | if c.cur().mon.Current().fullscreen != start { | |
| 513 | + | t.Fatalf("bind not applied: fullscreen %d", c.cur().mon.Current().fullscreen) | |
| 514 | + | } | |
| 515 | + | } | |
| 516 | + | ||
| 517 | + | func TestSwitchShiftTabGoesBackward(t *testing.T) { | |
| 518 | + | c, _ := switcherCore(t, 3) | |
| 519 | + | sendKeys(t, c, superKey(ports.ModSuper, true), shiftTab(true)) | |
| 520 | + | sw := c.cur().mon.sw | |
| 521 | + | if !sw.open || sw.at != len(sw.order)-1 { | |
| 522 | + | t.Fatalf("open %v at %d of %d", sw.open, sw.at, len(sw.order)) | |
| 523 | + | } | |
| 524 | + | sendKeys(t, c, shiftTab(false), tab(ports.ModSuper, true)) | |
| 525 | + | if c.cur().mon.sw.at != 0 { | |
| 526 | + | t.Fatalf("at %d, want 0 after next", c.cur().mon.sw.at) | |
| 527 | + | } | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | func TestSwitchSwipeBeginCancels(t *testing.T) { | |
| 531 | + | c, _ := switcherCore(t, 3) | |
| 532 | + | start := switcherFocus(c) | |
| 533 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |
| 534 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |
| 535 | + | if c.cur().mon.sw.open || c.switcher.sc != nil || c.switcher.timerC != nil || switcherFocus(c) != start { | |
| 536 | + | t.Fatal("swipe did not cancel the switcher") | |
| 537 | + | } | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | func TestSwitchSecurityChangeCancels(t *testing.T) { | |
| 541 | + | c, _ := switcherCore(t, 3) | |
| 542 | + | state := ports.SecurityState{Generation: 1} | |
| 543 | + | gate := portsmocks.NewMockSessionSecurity(t) | |
| 544 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }).Maybe() | |
| 545 | + | c.opts.Security = gate | |
| 546 | + | start := switcherFocus(c) | |
| 547 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |
| 548 | + | state = ports.SecurityState{Generation: 2} | |
| 549 | + | if !c.syncSecurity() { | |
| 550 | + | t.Fatal("security change not seen") | |
| 551 | + | } | |
| 552 | + | if c.cur().mon.sw.open || c.switcher.sc != nil || c.switcher.timerC != nil || switcherFocus(c) != start { | |
| 553 | + | t.Fatal("security change did not cancel the switcher") | |
| 554 | + | } | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | func TestSwitchCmdKeyFollowsConfig(t *testing.T) { | |
| 558 | + | c, _ := switcherCore(t, 3) | |
| 559 | + | cfg := c.cfg | |
| 560 | + | cfg.Keyboard.CmdKey = "alt" | |
| 561 | + | if err := c.apply(cfg); err != nil { | |
| 562 | + | t.Fatal(err) | |
| 563 | + | } | |
| 564 | + | order := c.cur().mon.Current().switchOrder() | |
| 565 | + | sendKeys(t, c, tab(ports.ModAlt, true)) | |
| 566 | + | if !c.cur().mon.sw.open { | |
| 567 | + | t.Fatal("Alt+Tab did not open the switcher") | |
| 568 | + | } | |
| 569 | + | sendKeys(t, c, ports.KeyEvent{Keysym: "Alt_L", Keycode: 64, Pressed: false}) | |
| 570 | + | if c.cur().mon.sw.open || switcherFocus(c) != order[1] { | |
| 571 | + | t.Fatalf("open %v focus %d, want %d", c.cur().mon.sw.open, switcherFocus(c), order[1]) | |
| 572 | + | } | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | // --- P3.2: layout --- | |
| 576 | + | ||
| 577 | + | func TestSwitcherLayoutCards(t *testing.T) { | |
| 578 | + | c, _ := switcherCore(t, 3) | |
| 579 | + | c.cfg.Floating.Dim = 0.4 | |
| 580 | + | area := c.cur().mon.Current().overviewArea() | |
| 581 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |
| 582 | + | if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 { | |
| 583 | + | t.Fatalf("cards before the delay: %+v", s.Windows) | |
| 584 | + | } | |
| 585 | + | order := c.cur().mon.sw.order | |
| 586 | + | c.switcherTick() | |
| 587 | + | s := indicatorScene(t, c) | |
| 588 | + | cards := scenePreviews(s) | |
| 589 | + | if len(cards) != 3 { | |
| 590 | + | t.Fatalf("%d cards: %+v", len(cards), s.Windows) | |
| 591 | + | } | |
| 592 | + | minX, maxX := 1<<30, 0 | |
| 593 | + | focused := 0 | |
| 594 | + | for k, card := range cards { | |
| 595 | + | if card.ID != order[k] { | |
| 596 | + | t.Fatalf("card %d is window %d, want %d (MRU order)", k, card.ID, order[k]) | |
| 597 | + | } | |
| 598 | + | if card.Rect.W < cards[0].Rect.W-1 || card.Rect.W > cards[0].Rect.W+1 { | |
| 599 | + | t.Fatalf("cards of different widths: %+v", cards) | |
| 600 | + | } | |
| 601 | + | minX, maxX = min(minX, card.Rect.X), max(maxX, card.Rect.X+card.Rect.W) | |
| 602 | + | if card.Focused { | |
| 603 | + | focused++ | |
| 604 | + | if card.ID != order[c.cur().mon.sw.at] { | |
| 605 | + | t.Fatalf("focused card %d is not the selected %d", card.ID, order[c.cur().mon.sw.at]) | |
| 606 | + | } | |
| 607 | + | } | |
| 608 | + | } | |
| 609 | + | left, right := minX-area.X, area.X+area.W-maxX | |
| 610 | + | if d := left - right; d < -1 || d > 1 { | |
| 611 | + | t.Fatalf("row not centered: left gap %d, right gap %d", left, right) | |
| 612 | + | } | |
| 613 | + | if focused != 1 { | |
| 614 | + | t.Fatalf("%d focused cards", focused) | |
| 615 | + | } | |
| 616 | + | if len(s.Separators) != 4 { | |
| 617 | + | t.Fatalf("separators %+v, want the 4 lines of the outline", s.Separators) | |
| 618 | + | } | |
| 619 | + | if s.Dim != 0.4 { | |
| 620 | + | t.Fatalf("dim %v", s.Dim) | |
| 621 | + | } | |
| 622 | + | if s.TileInset != (ports.Insets{}) { | |
| 623 | + | t.Fatalf("tile inset %+v", s.TileInset) | |
| 624 | + | } | |
| 625 | + | for _, w := range s.Windows { | |
| 626 | + | if w.Preview == 0 && !w.Hidden { | |
| 627 | + | t.Fatalf("window %d drawn under the cards", w.ID) | |
| 628 | + | } | |
| 629 | + | } | |
| 630 | + | // The keyboard stays on the real focus. | |
| 631 | + | if c.keyboardFocus() != focusedIDReal(c) { | |
| 632 | + | t.Fatalf("keyboard focus %d moved", c.keyboardFocus()) | |
| 633 | + | } | |
| 634 | + | // A commit gives the workspace back. | |
| 635 | + | sendKeys(t, c, superKey(0, false)) | |
| 636 | + | if s := lastScene(t, c); len(scenePreviews(s)) != 0 { | |
| 637 | + | t.Fatalf("cards after the commit: %+v", s.Windows) | |
| 638 | + | } | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | func focusedIDReal(c *Core) WindowID { | |
| 642 | + | id, _ := c.cur().mon.Current().Focused() | |
| 643 | + | return id | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | func TestSwitcherLayoutHidesOtherWindowsOfColumns(t *testing.T) { | |
| 647 | + | c, _ := switcherCore(t, 3) | |
| 648 | + | c.cur().mon.AddWindow(4) | |
| 649 | + | w := c.cur().mon.Current() | |
| 650 | + | w.ConsumeOrExpel(-1) | |
| 651 | + | if len(w.Columns) != 3 || w.columnWindows() != 4 { | |
| 652 | + | t.Fatalf("setup: %d columns, %d windows", len(w.Columns), w.columnWindows()) | |
| 653 | + | } | |
| 654 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |
| 655 | + | c.switcherTick() | |
| 656 | + | s := indicatorScene(t, c) | |
| 657 | + | reps := map[WindowID]bool{} | |
| 658 | + | for _, id := range c.cur().mon.sw.order { | |
| 659 | + | reps[id] = true | |
| 660 | + | } | |
| 661 | + | for _, sw := range s.Windows { | |
| 662 | + | if reps[sw.ID] { | |
| 663 | + | if sw.Preview == 0 || sw.Hidden { | |
| 664 | + | t.Fatalf("representative %d not a card: %+v", sw.ID, sw) | |
| 665 | + | } | |
| 666 | + | } else if !sw.Hidden { | |
| 667 | + | t.Fatalf("window %d not hidden: %+v", sw.ID, sw) | |
| 668 | + | } | |
| 669 | + | } | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | func TestSwitcherCardsKeepKeysOnRealFocus(t *testing.T) { | |
| 673 | + | c, _ := switcherCore(t, 3) | |
| 674 | + | focus := switcherFocus(c) | |
| 675 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |
| 676 | + | c.switcherTick() | |
| 677 | + | indicatorScene(t, c) | |
| 678 | + | forwarded(c) | |
| 679 | + | if c.keyboardFocus() != focus { | |
| 680 | + | t.Fatalf("keyboard focus %d, want %d", c.keyboardFocus(), focus) | |
| 681 | + | } | |
| 682 | + | sendKeys(t, c, ports.KeyEvent{Keysym: "x", Keycode: 45, Mods: ports.ModSuper, Pressed: true}) | |
| 683 | + | // An unbound key goes to the real focus (Cmd+x has no bind here). | |
| 684 | + | fk := forwarded(c) | |
| 685 | + | if len(fk) != 1 || fk[0].ID != focus || fk[0].Key.Keysym != "x" { | |
| 686 | + | t.Fatalf("forwarded %+v, want x to %d", fk, focus) | |
| 687 | + | } | |
| 688 | + | } | |
| 689 | + | ||
| 690 | + | // --- P3.3: click --- | |
| 691 | + | ||
| 692 | + | func cardCenter(t *testing.T, c *Core, id WindowID) (float64, float64) { | |
| 693 | + | t.Helper() | |
| 694 | + | for _, p := range c.cur().shownLayout() { | |
| 695 | + | if p.ID == id && p.Preview > 0 { | |
| 696 | + | return float64(p.Rect.X + p.Rect.W/2), float64(p.Rect.Y + p.Rect.H/2) | |
| 697 | + | } | |
| 698 | + | } | |
| 699 | + | t.Fatalf("no card for %d", id) | |
| 700 | + | return 0, 0 | |
| 701 | + | } | |
| 702 | + | ||
| 703 | + | func TestSwitcherClickOnCardCommits(t *testing.T) { | |
| 704 | + | c, _ := switcherCore(t, 3) | |
| 705 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |
| 706 | + | c.switcherTick() | |
| 707 | + | indicatorScene(t, c) | |
| 708 | + | target := c.cur().mon.sw.order[2] | |
| 709 | + | c.cursorX, c.cursorY = cardCenter(t, c, target) | |
| 710 | + | forwarded(c) | |
| 711 | + | if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil { | |
| 712 | + | t.Fatal(err) | |
| 713 | + | } | |
| 714 | + | if c.cur().mon.sw.open || c.switcher.sc != nil || switcherFocus(c) != target { | |
| 715 | + | t.Fatalf("open %v focus %d, want %d", c.cur().mon.sw.open, switcherFocus(c), target) | |
| 716 | + | } | |
| 717 | + | if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110}); err != nil { | |
| 718 | + | t.Fatal(err) | |
| 719 | + | } | |
| 720 | + | ch := switcherCommands[c] | |
| 721 | + | for len(ch) > 0 { | |
| 722 | + | if v, ok := (<-ch).(ports.PointerButtonTo); ok { | |
| 723 | + | t.Fatalf("click reached a client: %+v", v) | |
| 724 | + | } | |
| 725 | + | } | |
| 726 | + | } | |
| 727 | + | ||
| 728 | + | func TestSwitcherClickElsewhereCancels(t *testing.T) { | |
| 729 | + | c, _ := switcherCore(t, 3) | |
| 730 | + | start := switcherFocus(c) | |
| 731 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |
| 732 | + | c.switcherTick() | |
| 733 | + | indicatorScene(t, c) | |
| 734 | + | c.cursorX, c.cursorY = 1, 1 | |
| 735 | + | if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil { | |
| 736 | + | t.Fatal(err) | |
| 737 | + | } | |
| 738 | + | if c.cur().mon.sw.open || c.switcher.sc != nil || switcherFocus(c) != start { | |
| 739 | + | t.Fatalf("open %v focus %d, want %d", c.cur().mon.sw.open, switcherFocus(c), start) | |
| 740 | + | } | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | func TestSwitcherPressBeforeCardsCancelsAndContinues(t *testing.T) { | |
| 744 | + | c, _ := switcherCore(t, 3) | |
| 745 | + | start := switcherFocus(c) | |
| 746 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |
| 747 | + | handled, err := c.switcherClick(context.Background(), 0x110) | |
| 748 | + | if err != nil || handled { | |
| 749 | + | t.Fatalf("handled %v err %v", handled, err) | |
| 750 | + | } | |
| 751 | + | if c.cur().mon.sw.open || c.switcher.timerC != nil || switcherFocus(c) != start { | |
| 752 | + | t.Fatal("press did not cancel") | |
| 753 | + | } | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | // --- P3.4: robustness --- | |
| 757 | + | ||
| 758 | + | func TestSwitcherClosingSelectedWindowKeepsSelectionValid(t *testing.T) { | |
| 759 | + | c, _ := switcherCore(t, 3) | |
| 760 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |
| 761 | + | c.switcherTick() | |
| 762 | + | sw := &c.cur().mon.sw | |
| 763 | + | gone := sw.order[sw.at] | |
| 764 | + | c.cur().mon.RemoveWindow(gone) | |
| 765 | + | if !sw.open || len(sw.order) != 2 || sw.at < 0 || sw.at >= len(sw.order) || slices.Contains(sw.order, gone) { | |
| 766 | + | t.Fatalf("open %v order %v at %d", sw.open, sw.order, sw.at) | |
| 767 | + | } | |
| 768 | + | s := indicatorScene(t, c) | |
| 769 | + | if n := len(scenePreviews(s)); n != 2 { | |
| 770 | + | t.Fatalf("%d cards after a close", n) | |
| 771 | + | } | |
| 772 | + | want := sw.order[sw.at] | |
| 773 | + | sendKeys(t, c, superKey(0, false)) | |
| 774 | + | if switcherFocus(c) != want { | |
| 775 | + | t.Fatalf("focus %d, want neighbour %d", switcherFocus(c), want) | |
| 776 | + | } | |
| 777 | + | } | |
| 778 | + | ||
| 779 | + | func TestSwitcherClosingBeforeSelectionKeepsCard(t *testing.T) { | |
| 780 | + | c, _ := switcherCore(t, 3) | |
| 781 | + | c.mods = ports.ModSuper | |
| 782 | + | c.runBind(context.Background(), ActionSwitchColumnPrev) | |
| 783 | + | sw := &c.cur().mon.sw | |
| 784 | + | selected := sw.order[sw.at] | |
| 785 | + | c.cur().mon.RemoveWindow(sw.order[0]) | |
| 786 | + | if !sw.open || sw.order[sw.at] != selected { | |
| 787 | + | t.Fatalf("selection moved from %d: order %v at %d", selected, sw.order, sw.at) | |
| 788 | + | } | |
| 789 | + | } | |
| 790 | + | ||
| 791 | + | func TestSwitcherClosingDownToOneColumnClosesIt(t *testing.T) { | |
| 792 | + | c, _ := switcherCore(t, 2) | |
| 793 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |
| 794 | + | c.switcherTick() | |
| 795 | + | c.cur().mon.RemoveWindow(c.cur().mon.sw.order[1]) | |
| 796 | + | if c.cur().mon.sw.open || c.cur().mon.sw.shown { | |
| 797 | + | t.Fatal("switcher still open with one column") | |
| 798 | + | } | |
| 799 | + | if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 { | |
| 800 | + | t.Fatalf("cards left: %+v", s.Windows) | |
| 801 | + | } | |
| 802 | + | // The timer fires on a closed switcher: harmless. | |
| 803 | + | if c.switcherTick() { | |
| 804 | + | t.Fatal("tick on a closed switcher") | |
| 805 | + | } | |
| 806 | + | sendKeys(t, c, superKey(0, false)) | |
| 807 | + | } | |
| 808 | + | ||
| 809 | + | func TestSwitcherOutputRemovalCancels(t *testing.T) { | |
| 810 | + | c, _ := switcherCore(t, 3) | |
| 811 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |
| 812 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |
| 813 | + | c.switcherTick() | |
| 814 | + | sc := c.switcher.sc | |
| 815 | + | if sc == nil { | |
| 816 | + | t.Fatal("no switcher screen") | |
| 817 | + | } | |
| 818 | + | c.removeScreen(sc.name()) | |
| 819 | + | if c.switcher.sc != nil || c.switcher.timerC != nil || sc.mon.sw.open { | |
| 820 | + | t.Fatal("switcher survived its output") | |
| 821 | + | } | |
| 822 | + | if err := c.publish(context.Background()); err != nil { | |
| 823 | + | t.Fatal(err) | |
| 824 | + | } | |
| 825 | + | if c.switcherTick() { | |
| 826 | + | t.Fatal("tick after the output went") | |
| 827 | + | } | |
| 828 | + | } | |
| 829 | + | ||
| 830 | + | func TestSwitcherFocusMovingToAnotherOutputReopensThere(t *testing.T) { | |
| 831 | + | c, _ := switcherCore(t, 3) | |
| 832 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |
| 833 | + | first := c.cur() | |
| 834 | + | for id := WindowID(11); id <= 13; id++ { | |
| 835 | + | c.screens[c.screenIndex("B")].mon.AddWindow(id) | |
| 836 | + | } | |
| 837 | + | indicatorScene(t, c) | |
| 838 | + | c.mods = ports.ModSuper | |
| 839 | + | c.runBind(context.Background(), ActionSwitchColumnNext) | |
| 840 | + | if c.switcher.sc != first { | |
| 841 | + | t.Fatal("switcher not on the focused screen") | |
| 842 | + | } | |
| 843 | + | c.focusScreen = c.screenIndex("B") | |
| 844 | + | c.runBind(context.Background(), ActionSwitchColumnNext) | |
| 845 | + | if first.mon.sw.open { | |
| 846 | + | t.Fatal("old switcher left open") | |
| 847 | + | } | |
| 848 | + | if c.switcher.sc != c.cur() || !c.cur().mon.sw.open || c.cur().mon.sw.at != 1 { | |
| 849 | + | t.Fatalf("new switcher on %v open %v", c.switcher.sc == c.cur(), c.cur().mon.sw.open) | |
| 850 | + | } | |
| 851 | + | } | |
| 852 | + | ||
| 853 | + | // With animations on, the cards fly in and the commit flies them back; the | |
| 854 | + | // windows end where the layout puts them, with no card left. | |
| 855 | + | func TestSwitcherAnimatedHoldAndCommit(t *testing.T) { | |
| 856 | + | c, ic := switcherCore(t, 3) | |
| 857 | + | c.cfg.Animations.On = true | |
| 858 | + | target := c.cur().mon.Current().switchOrder()[1] | |
| 859 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |
| 860 | + | c.switcherTick() | |
| 861 | + | if err := c.publish(context.Background()); err != nil { | |
| 862 | + | t.Fatal(err) | |
| 863 | + | } | |
| 864 | + | if len(scenePreviews(lastScene(t, c))) != 3 { | |
| 865 | + | t.Fatal("cards not shown") | |
| 866 | + | } | |
| 867 | + | sendKeys(t, c, superKey(0, false)) | |
| 868 | + | for range 200 { | |
| 869 | + | ic.now = ic.now.Add(16 * time.Millisecond) | |
| 870 | + | if err := c.step(context.Background(), nil); err != nil { | |
| 871 | + | t.Fatal(err) | |
| 872 | + | } | |
| 873 | + | } | |
| 874 | + | s := lastScene(t, c) | |
| 875 | + | if len(scenePreviews(s)) != 0 || switcherFocus(c) != target { | |
| 876 | + | t.Fatalf("cards %d, focus %d (want %d)", len(scenePreviews(s)), switcherFocus(c), target) | |
| 877 | + | } | |
| 878 | + | } | |
| @@ -1,8 +1,13 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "slices" | 5 | "slices" | ||
| 5 | "testing" | 6 | "testing" | ||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 6 | ) | 11 | ) | ||
| 7 | 12 | ||||
| 8 | // threeColumns is a workspace of three one-window columns, windows 1..3. | 13 | // threeColumns is a workspace of three one-window columns, windows 1..3. | ||
| @@ -127,3 +132,747 @@ func TestMRUNoteFocusDoesNotAllocateWhenSettled(t *testing.T) { | |||||
| 127 | t.Fatalf("noteFocus allocates %v", n) | 132 | t.Fatalf("noteFocus allocates %v", n) | ||
| 128 | } | 133 | } | ||
| 129 | } | 134 | } | ||
| 135 | + | ||||
| 136 | + | // switcherBinds are the default binds of the switcher. | |||
| 137 | + | var switcherBinds = map[string]string{ | |||
| 138 | + | "Cmd+Tab": string(ActionSwitchColumnNext), | |||
| 139 | + | "Cmd+Shift+Tab": string(ActionSwitchColumnPrev), | |||
| 140 | + | "Cmd+f": string(ActionToggleFullscreen), | |||
| 141 | + | "F9": string(ActionSwitchColumnNext), | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | const ( | |||
| 145 | + | keyTab = 15 | |||
| 146 | + | keySuper = 133 | |||
| 147 | + | ) | |||
| 148 | + | ||||
| 149 | + | // switcherCore is indicatorCore with the switcher binds, up to three | |||
| 150 | + | // columns on screen and n one-window columns (windows 1..n, n focused), | |||
| 151 | + | // published once so the configures carry the windows' sizes. | |||
| 152 | + | func switcherCore(t *testing.T, n int) (*Core, *indicatorClock) { | |||
| 153 | + | t.Helper() | |||
| 154 | + | commands := make(chan ports.ClientCommand, 64) | |||
| 155 | + | c, ic := indicatorCoreWith(t, commands) | |||
| 156 | + | switcherCommands[c] = commands | |||
| 157 | + | t.Cleanup(func() { delete(switcherCommands, c) }) | |||
| 158 | + | cfg := c.cfg | |||
| 159 | + | cfg.Layout.MaxColumns = 3 | |||
| 160 | + | cfg.Binds = switcherBinds | |||
| 161 | + | if err := c.apply(cfg); err != nil { | |||
| 162 | + | t.Fatal(err) | |||
| 163 | + | } | |||
| 164 | + | for id := WindowID(1); id <= WindowID(n); id++ { | |||
| 165 | + | c.cur().mon.AddWindow(id) | |||
| 166 | + | } | |||
| 167 | + | indicatorScene(t, c) | |||
| 168 | + | return c, ic | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func tab(mods ports.Mods, pressed bool) ports.KeyEvent { | |||
| 172 | + | return ports.KeyEvent{Keysym: "Tab", Keycode: keyTab, Mods: mods, Pressed: pressed} | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func shiftTab(pressed bool) ports.KeyEvent { | |||
| 176 | + | return ports.KeyEvent{Keysym: "ISO_Left_Tab", Base: "Tab", Keycode: keyTab, Mods: ports.ModSuper | ports.ModShift, Pressed: pressed} | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func superKey(mods ports.Mods, pressed bool) ports.KeyEvent { | |||
| 180 | + | return ports.KeyEvent{Keysym: "Super_L", Keycode: keySuper, Mods: mods, Pressed: pressed} | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func sendKeys(t *testing.T, c *Core, keys ...ports.KeyEvent) { | |||
| 184 | + | t.Helper() | |||
| 185 | + | for _, k := range keys { | |||
| 186 | + | if err := c.keyEvent(context.Background(), k); err != nil { | |||
| 187 | + | t.Fatal(err) | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // lastScene returns the latest published scene. | |||
| 193 | + | func lastScene(t *testing.T, c *Core) ports.Scene { | |||
| 194 | + | t.Helper() | |||
| 195 | + | var s []ports.Scene | |||
| 196 | + | for len(c.ch.Scenes) > 0 { | |||
| 197 | + | s = <-c.ch.Scenes | |||
| 198 | + | } | |||
| 199 | + | if s == nil { | |||
| 200 | + | t.Fatal("no scene published") | |||
| 201 | + | } | |||
| 202 | + | return s[0] | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func switcherFocus(c *Core) WindowID { | |||
| 206 | + | id, _ := c.cur().mon.Focused() | |||
| 207 | + | return id | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | // switcherCommands is the receiving end of each switcherCore's commands. | |||
| 211 | + | var switcherCommands = map[*Core]chan ports.ClientCommand{} | |||
| 212 | + | ||||
| 213 | + | // forwarded drains the commands and returns the forwarded keys. | |||
| 214 | + | func forwarded(c *Core) []ports.ForwardKey { | |||
| 215 | + | var out []ports.ForwardKey | |||
| 216 | + | ch := switcherCommands[c] | |||
| 217 | + | for len(ch) > 0 { | |||
| 218 | + | if v, ok := (<-ch).(ports.ForwardKey); ok { | |||
| 219 | + | out = append(out, v) | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | return out | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func scenePreviews(s ports.Scene) []ports.SceneWindow { | |||
| 226 | + | var out []ports.SceneWindow | |||
| 227 | + | for _, w := range s.Windows { | |||
| 228 | + | if w.Preview > 0 { | |||
| 229 | + | out = append(out, w) | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | return out | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // --- P2.1: commit --- | |||
| 236 | + | ||||
| 237 | + | func switcherMonitor(overflow Overflow, n int) (*Monitor, *Workspace) { | |||
| 238 | + | m := NewMonitor() | |||
| 239 | + | m.SetOutput(300, 200) | |||
| 240 | + | m.SetMaxColumns(3) | |||
| 241 | + | m.SetOverflow(overflow) | |||
| 242 | + | for id := WindowID(1); id <= WindowID(n); id++ { | |||
| 243 | + | m.AddWindow(id) | |||
| 244 | + | } | |||
| 245 | + | return m, m.Current() | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func openSwitcher(m *Monitor, at int) { | |||
| 249 | + | w := m.Current() | |||
| 250 | + | m.sw = switcherState{open: true, ws: w, order: w.switchOrder(), at: at} | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func TestSwitchCommitFocusesSelectedColumn(t *testing.T) { | |||
| 254 | + | m, w := switcherMonitor(OverflowScroll, 3) | |||
| 255 | + | openSwitcher(m, 1) | |||
| 256 | + | want := m.sw.order[1] | |||
| 257 | + | m.commitSwitcher() | |||
| 258 | + | if id, _ := w.Focused(); id != want || m.sw.open { | |||
| 259 | + | t.Fatalf("focus %d (want %d), switcher open %v", id, want, m.sw.open) | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | func TestSwitchCommitTransfersMaximization(t *testing.T) { | |||
| 264 | + | for _, overflow := range []Overflow{OverflowCascade, OverflowFixed, OverflowScroll} { | |||
| 265 | + | t.Run(string(overflow), func(t *testing.T) { | |||
| 266 | + | m, w := switcherMonitor(overflow, 2) | |||
| 267 | + | a, b := w.Focus, 1-w.Focus | |||
| 268 | + | w.FocusID(w.Columns[a].Windows[0]) | |||
| 269 | + | w.ToggleFullWidth() | |||
| 270 | + | w.noteFocus() | |||
| 271 | + | if !w.Columns[a].FullWidth { | |||
| 272 | + | t.Fatal("setup: source not maximized") | |||
| 273 | + | } | |||
| 274 | + | openSwitcher(m, 1) | |||
| 275 | + | m.commitSwitcher() | |||
| 276 | + | if w.Focus != b || !w.Columns[b].FullWidth || w.Columns[a].FullWidth { | |||
| 277 | + | t.Fatalf("focus %d, FullWidth source %v target %v", w.Focus, w.Columns[a].FullWidth, w.Columns[b].FullWidth) | |||
| 278 | + | } | |||
| 279 | + | if w.policy().equalCells && !w.hiddenByMaximized(a) { | |||
| 280 | + | t.Fatal("source not hidden by the maximized target") | |||
| 281 | + | } | |||
| 282 | + | }) | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | func TestSwitchCommitLeavesPinnedFullscreen(t *testing.T) { | |||
| 287 | + | m, w := switcherMonitor(OverflowFixed, 2) | |||
| 288 | + | src := w.Columns[w.Focus].Windows[0] | |||
| 289 | + | w.SetFullscreen(src, true) | |||
| 290 | + | if w.fullscreen != src { | |||
| 291 | + | t.Fatal("setup: no fullscreen") | |||
| 292 | + | } | |||
| 293 | + | openSwitcher(m, 1) | |||
| 294 | + | target := m.sw.order[1] | |||
| 295 | + | m.commitSwitcher() | |||
| 296 | + | if w.fullscreen != 0 { | |||
| 297 | + | t.Fatalf("fullscreen %d kept", w.fullscreen) | |||
| 298 | + | } | |||
| 299 | + | if id, _ := w.Focused(); id != target { | |||
| 300 | + | t.Fatalf("focus %d, want %d", id, target) | |||
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | func TestSwitchCommitIgnoresGoneWindowAndOtherWorkspace(t *testing.T) { | |||
| 305 | + | m, w := switcherMonitor(OverflowScroll, 3) | |||
| 306 | + | before, _ := w.Focused() | |||
| 307 | + | openSwitcher(m, 1) | |||
| 308 | + | m.sw.order[1] = 99 // not in the workspace any more | |||
| 309 | + | m.commitSwitcher() | |||
| 310 | + | if id, _ := w.Focused(); id != before || m.sw.open { | |||
| 311 | + | t.Fatalf("focus %d, want %d; open %v", id, before, m.sw.open) | |||
| 312 | + | } | |||
| 313 | + | openSwitcher(m, 1) | |||
| 314 | + | m.sw.ws = &Workspace{} | |||
| 315 | + | m.commitSwitcher() | |||
| 316 | + | if id, _ := w.Focused(); id != before || m.sw.open { | |||
| 317 | + | t.Fatalf("focus %d for a foreign workspace, want %d", id, before) | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | // --- P2.2: actions and step --- | |||
| 322 | + | ||||
| 323 | + | func TestSwitchStepOpensOnPreviousColumnAndCycles(t *testing.T) { | |||
| 324 | + | c, ic := switcherCore(t, 3) | |||
| 325 | + | c.mods = ports.ModSuper | |||
| 326 | + | before := switcherFocus(c) | |||
| 327 | + | if err := c.runBind(context.Background(), ActionSwitchColumnNext); err != nil { | |||
| 328 | + | t.Fatal(err) | |||
| 329 | + | } | |||
| 330 | + | sw := &c.cur().mon.sw | |||
| 331 | + | if !sw.open || sw.at != 1 || switcherFocus(c) != before { | |||
| 332 | + | t.Fatalf("open %v at %d focus %d (was %d)", sw.open, sw.at, switcherFocus(c), before) | |||
| 333 | + | } | |||
| 334 | + | if len(ic.timers) != 1 || ic.timers[0] != switcherDelay { | |||
| 335 | + | t.Fatalf("timers %v, want one of %v", ic.timers, switcherDelay) | |||
| 336 | + | } | |||
| 337 | + | c.runBind(context.Background(), ActionSwitchColumnNext) | |||
| 338 | + | if sw.at != 2 { | |||
| 339 | + | t.Fatalf("at %d", sw.at) | |||
| 340 | + | } | |||
| 341 | + | c.runBind(context.Background(), ActionSwitchColumnNext) | |||
| 342 | + | if sw.at != 0 { | |||
| 343 | + | t.Fatalf("at %d, want wrap to 0", sw.at) | |||
| 344 | + | } | |||
| 345 | + | c.runBind(context.Background(), ActionSwitchColumnPrev) | |||
| 346 | + | if sw.at != 2 { | |||
| 347 | + | t.Fatalf("at %d, want wrap to 2", sw.at) | |||
| 348 | + | } | |||
| 349 | + | c.runBind(context.Background(), ActionSwitchColumnPrev) | |||
| 350 | + | if sw.at != 1 { | |||
| 351 | + | t.Fatalf("at %d", sw.at) | |||
| 352 | + | } | |||
| 353 | + | if len(ic.timers) != 1 { | |||
| 354 | + | t.Fatalf("timers %v: stepping must not re-arm", ic.timers) | |||
| 355 | + | } | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | func TestSwitchPrevOpensOnOldestColumn(t *testing.T) { | |||
| 359 | + | c, _ := switcherCore(t, 3) | |||
| 360 | + | c.mods = ports.ModSuper | |||
| 361 | + | c.runBind(context.Background(), ActionSwitchColumnPrev) | |||
| 362 | + | if sw := c.cur().mon.sw; !sw.open || sw.at != len(sw.order)-1 { | |||
| 363 | + | t.Fatalf("at %d of %d", sw.at, len(sw.order)) | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func TestSwitchWithoutCmdCommitsAtOnce(t *testing.T) { | |||
| 368 | + | c, ic := switcherCore(t, 3) | |||
| 369 | + | c.mods = 0 | |||
| 370 | + | start := switcherFocus(c) | |||
| 371 | + | if err := c.runBind(context.Background(), ActionSwitchColumnNext); err != nil { | |||
| 372 | + | t.Fatal(err) | |||
| 373 | + | } | |||
| 374 | + | if c.cur().mon.sw.open || c.switcher.timerC != nil || len(ic.timers) != 0 { | |||
| 375 | + | t.Fatalf("open %v, timer %v %v", c.cur().mon.sw.open, c.switcher.timerC != nil, ic.timers) | |||
| 376 | + | } | |||
| 377 | + | if switcherFocus(c) == start { | |||
| 378 | + | t.Fatal("focus did not move") | |||
| 379 | + | } | |||
| 380 | + | c.runBind(context.Background(), ActionSwitchColumnNext) | |||
| 381 | + | if switcherFocus(c) != start { | |||
| 382 | + | t.Fatalf("focus %d, want back on %d", switcherFocus(c), start) | |||
| 383 | + | } | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | func TestSwitchIgnoredWithOneColumnOrOverview(t *testing.T) { | |||
| 387 | + | c, _ := switcherCore(t, 1) | |||
| 388 | + | c.mods = ports.ModSuper | |||
| 389 | + | c.runBind(context.Background(), ActionSwitchColumnNext) | |||
| 390 | + | if c.cur().mon.sw.open || c.switcher.sc != nil { | |||
| 391 | + | t.Fatal("switcher opened with one column") | |||
| 392 | + | } | |||
| 393 | + | c, _ = switcherCore(t, 3) | |||
| 394 | + | c.cur().mon.ToggleOverview() | |||
| 395 | + | before := switcherFocus(c) | |||
| 396 | + | c.runBind(context.Background(), ActionSwitchColumnNext) | |||
| 397 | + | if c.cur().mon.sw.open || c.switcher.sc != nil || switcherFocus(c) != before { | |||
| 398 | + | t.Fatal("switcher acted in the overview") | |||
| 399 | + | } | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | // --- P2.3: release commit and timer --- | |||
| 403 | + | ||||
| 404 | + | func TestSwitchQuickTapSwapsWithoutCards(t *testing.T) { | |||
| 405 | + | c, _ := switcherCore(t, 3) | |||
| 406 | + | start := switcherFocus(c) | |||
| 407 | + | for len(c.ch.Scenes) > 0 { | |||
| 408 | + | <-c.ch.Scenes | |||
| 409 | + | } | |||
| 410 | + | tap := func() { | |||
| 411 | + | t.Helper() | |||
| 412 | + | sendKeys(t, c, | |||
| 413 | + | superKey(ports.ModSuper, true), | |||
| 414 | + | tab(ports.ModSuper, true), | |||
| 415 | + | tab(ports.ModSuper, false), | |||
| 416 | + | superKey(0, false), | |||
| 417 | + | ) | |||
| 418 | + | for len(c.ch.Scenes) > 0 { | |||
| 419 | + | if p := scenePreviews((<-c.ch.Scenes)[0]); len(p) > 0 { | |||
| 420 | + | t.Fatalf("cards published during a quick tap: %+v", p) | |||
| 421 | + | } | |||
| 422 | + | } | |||
| 423 | + | if c.cur().mon.sw.open || c.switcher.sc != nil || c.switcher.timerC != nil { | |||
| 424 | + | t.Fatal("switcher left open") | |||
| 425 | + | } | |||
| 426 | + | } | |||
| 427 | + | tap() | |||
| 428 | + | if switcherFocus(c) == start { | |||
| 429 | + | t.Fatal("tap did not swap") | |||
| 430 | + | } | |||
| 431 | + | tap() | |||
| 432 | + | if switcherFocus(c) != start { | |||
| 433 | + | t.Fatalf("second tap focus %d, want back on %d", switcherFocus(c), start) | |||
| 434 | + | } | |||
| 435 | + | } | |||
| 436 | + | ||||
| 437 | + | func TestSwitchHoldCommitsOnReleaseAndForwardsIt(t *testing.T) { | |||
| 438 | + | c, _ := switcherCore(t, 3) | |||
| 439 | + | start := switcherFocus(c) | |||
| 440 | + | order := c.cur().mon.Current().switchOrder() | |||
| 441 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true), tab(ports.ModSuper, false)) | |||
| 442 | + | if switcherFocus(c) != start || !c.cur().mon.sw.open { | |||
| 443 | + | t.Fatal("focus moved or switcher closed before the release") | |||
| 444 | + | } | |||
| 445 | + | forwarded(c) | |||
| 446 | + | sendKeys(t, c, superKey(0, false)) | |||
| 447 | + | if switcherFocus(c) != order[1] || c.cur().mon.sw.open || c.switcher.timerC != nil { | |||
| 448 | + | t.Fatalf("focus %d (want %d), open %v", switcherFocus(c), order[1], c.cur().mon.sw.open) | |||
| 449 | + | } | |||
| 450 | + | fk := forwarded(c) | |||
| 451 | + | if len(fk) != 1 || fk[0].Key.Keysym != "Super_L" || fk[0].Key.Pressed || fk[0].ID != order[1] { | |||
| 452 | + | t.Fatalf("release not forwarded to the new focus: %+v", fk) | |||
| 453 | + | } | |||
| 454 | + | } | |||
| 455 | + | ||||
| 456 | + | func TestSwitcherTickShowsCards(t *testing.T) { | |||
| 457 | + | c, _ := switcherCore(t, 3) | |||
| 458 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |||
| 459 | + | if c.cur().mon.sw.shown { | |||
| 460 | + | t.Fatal("shown before the delay") | |||
| 461 | + | } | |||
| 462 | + | if !c.switcherTick() || !c.cur().mon.sw.shown { | |||
| 463 | + | t.Fatal("tick did not show the switcher") | |||
| 464 | + | } | |||
| 465 | + | if c.switcher.timerC != nil { | |||
| 466 | + | t.Fatal("timer left") | |||
| 467 | + | } | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | func TestSwitcherTickAfterCloseIsHarmless(t *testing.T) { | |||
| 471 | + | c, _ := switcherCore(t, 3) | |||
| 472 | + | c.mods = ports.ModSuper | |||
| 473 | + | c.runBind(context.Background(), ActionSwitchColumnNext) | |||
| 474 | + | c.cancelSwitcher() | |||
| 475 | + | if c.switcherTick() { | |||
| 476 | + | t.Fatal("tick on a closed switcher asks for a scene") | |||
| 477 | + | } | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | // --- P3.1: keys and gestures --- | |||
| 481 | + | ||||
| 482 | + | func TestSwitchEscapeCancelsAndIsNotForwarded(t *testing.T) { | |||
| 483 | + | c, _ := switcherCore(t, 3) | |||
| 484 | + | start := switcherFocus(c) | |||
| 485 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true), tab(ports.ModSuper, false)) | |||
| 486 | + | forwarded(c) | |||
| 487 | + | esc := ports.KeyEvent{Keysym: "Escape", Keycode: 1, Mods: ports.ModSuper, Pressed: true} | |||
| 488 | + | sendKeys(t, c, esc) | |||
| 489 | + | esc.Pressed = false | |||
| 490 | + | sendKeys(t, c, esc, superKey(0, false)) | |||
| 491 | + | if c.cur().mon.sw.open || c.switcher.sc != nil || switcherFocus(c) != start { | |||
| 492 | + | t.Fatalf("open %v focus %d (want %d)", c.cur().mon.sw.open, switcherFocus(c), start) | |||
| 493 | + | } | |||
| 494 | + | for _, k := range forwarded(c) { | |||
| 495 | + | if k.Key.Keysym == "Escape" { | |||
| 496 | + | t.Fatalf("Escape forwarded: %+v", k) | |||
| 497 | + | } | |||
| 498 | + | } | |||
| 499 | + | } | |||
| 500 | + | ||||
| 501 | + | func TestSwitchAnotherBindCancelsThenRuns(t *testing.T) { | |||
| 502 | + | c, _ := switcherCore(t, 3) | |||
| 503 | + | start := switcherFocus(c) | |||
| 504 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |||
| 505 | + | sendKeys(t, c, ports.KeyEvent{Keysym: "f", Keycode: 33, Mods: ports.ModSuper, Pressed: true}) | |||
| 506 | + | if c.cur().mon.sw.open || c.switcher.sc != nil || c.switcher.timerC != nil { | |||
| 507 | + | t.Fatal("switcher still open") | |||
| 508 | + | } | |||
| 509 | + | if switcherFocus(c) != start { | |||
| 510 | + | t.Fatalf("focus %d moved", switcherFocus(c)) | |||
| 511 | + | } | |||
| 512 | + | if c.cur().mon.Current().fullscreen != start { | |||
| 513 | + | t.Fatalf("bind not applied: fullscreen %d", c.cur().mon.Current().fullscreen) | |||
| 514 | + | } | |||
| 515 | + | } | |||
| 516 | + | ||||
| 517 | + | func TestSwitchShiftTabGoesBackward(t *testing.T) { | |||
| 518 | + | c, _ := switcherCore(t, 3) | |||
| 519 | + | sendKeys(t, c, superKey(ports.ModSuper, true), shiftTab(true)) | |||
| 520 | + | sw := c.cur().mon.sw | |||
| 521 | + | if !sw.open || sw.at != len(sw.order)-1 { | |||
| 522 | + | t.Fatalf("open %v at %d of %d", sw.open, sw.at, len(sw.order)) | |||
| 523 | + | } | |||
| 524 | + | sendKeys(t, c, shiftTab(false), tab(ports.ModSuper, true)) | |||
| 525 | + | if c.cur().mon.sw.at != 0 { | |||
| 526 | + | t.Fatalf("at %d, want 0 after next", c.cur().mon.sw.at) | |||
| 527 | + | } | |||
| 528 | + | } | |||
| 529 | + | ||||
| 530 | + | func TestSwitchSwipeBeginCancels(t *testing.T) { | |||
| 531 | + | c, _ := switcherCore(t, 3) | |||
| 532 | + | start := switcherFocus(c) | |||
| 533 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |||
| 534 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |||
| 535 | + | if c.cur().mon.sw.open || c.switcher.sc != nil || c.switcher.timerC != nil || switcherFocus(c) != start { | |||
| 536 | + | t.Fatal("swipe did not cancel the switcher") | |||
| 537 | + | } | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | func TestSwitchSecurityChangeCancels(t *testing.T) { | |||
| 541 | + | c, _ := switcherCore(t, 3) | |||
| 542 | + | state := ports.SecurityState{Generation: 1} | |||
| 543 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 544 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }).Maybe() | |||
| 545 | + | c.opts.Security = gate | |||
| 546 | + | start := switcherFocus(c) | |||
| 547 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |||
| 548 | + | state = ports.SecurityState{Generation: 2} | |||
| 549 | + | if !c.syncSecurity() { | |||
| 550 | + | t.Fatal("security change not seen") | |||
| 551 | + | } | |||
| 552 | + | if c.cur().mon.sw.open || c.switcher.sc != nil || c.switcher.timerC != nil || switcherFocus(c) != start { | |||
| 553 | + | t.Fatal("security change did not cancel the switcher") | |||
| 554 | + | } | |||
| 555 | + | } | |||
| 556 | + | ||||
| 557 | + | func TestSwitchCmdKeyFollowsConfig(t *testing.T) { | |||
| 558 | + | c, _ := switcherCore(t, 3) | |||
| 559 | + | cfg := c.cfg | |||
| 560 | + | cfg.Keyboard.CmdKey = "alt" | |||
| 561 | + | if err := c.apply(cfg); err != nil { | |||
| 562 | + | t.Fatal(err) | |||
| 563 | + | } | |||
| 564 | + | order := c.cur().mon.Current().switchOrder() | |||
| 565 | + | sendKeys(t, c, tab(ports.ModAlt, true)) | |||
| 566 | + | if !c.cur().mon.sw.open { | |||
| 567 | + | t.Fatal("Alt+Tab did not open the switcher") | |||
| 568 | + | } | |||
| 569 | + | sendKeys(t, c, ports.KeyEvent{Keysym: "Alt_L", Keycode: 64, Pressed: false}) | |||
| 570 | + | if c.cur().mon.sw.open || switcherFocus(c) != order[1] { | |||
| 571 | + | t.Fatalf("open %v focus %d, want %d", c.cur().mon.sw.open, switcherFocus(c), order[1]) | |||
| 572 | + | } | |||
| 573 | + | } | |||
| 574 | + | ||||
| 575 | + | // --- P3.2: layout --- | |||
| 576 | + | ||||
| 577 | + | func TestSwitcherLayoutCards(t *testing.T) { | |||
| 578 | + | c, _ := switcherCore(t, 3) | |||
| 579 | + | c.cfg.Floating.Dim = 0.4 | |||
| 580 | + | area := c.cur().mon.Current().overviewArea() | |||
| 581 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |||
| 582 | + | if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 { | |||
| 583 | + | t.Fatalf("cards before the delay: %+v", s.Windows) | |||
| 584 | + | } | |||
| 585 | + | order := c.cur().mon.sw.order | |||
| 586 | + | c.switcherTick() | |||
| 587 | + | s := indicatorScene(t, c) | |||
| 588 | + | cards := scenePreviews(s) | |||
| 589 | + | if len(cards) != 3 { | |||
| 590 | + | t.Fatalf("%d cards: %+v", len(cards), s.Windows) | |||
| 591 | + | } | |||
| 592 | + | minX, maxX := 1<<30, 0 | |||
| 593 | + | focused := 0 | |||
| 594 | + | for k, card := range cards { | |||
| 595 | + | if card.ID != order[k] { | |||
| 596 | + | t.Fatalf("card %d is window %d, want %d (MRU order)", k, card.ID, order[k]) | |||
| 597 | + | } | |||
| 598 | + | if card.Rect.W < cards[0].Rect.W-1 || card.Rect.W > cards[0].Rect.W+1 { | |||
| 599 | + | t.Fatalf("cards of different widths: %+v", cards) | |||
| 600 | + | } | |||
| 601 | + | minX, maxX = min(minX, card.Rect.X), max(maxX, card.Rect.X+card.Rect.W) | |||
| 602 | + | if card.Focused { | |||
| 603 | + | focused++ | |||
| 604 | + | if card.ID != order[c.cur().mon.sw.at] { | |||
| 605 | + | t.Fatalf("focused card %d is not the selected %d", card.ID, order[c.cur().mon.sw.at]) | |||
| 606 | + | } | |||
| 607 | + | } | |||
| 608 | + | } | |||
| 609 | + | left, right := minX-area.X, area.X+area.W-maxX | |||
| 610 | + | if d := left - right; d < -1 || d > 1 { | |||
| 611 | + | t.Fatalf("row not centered: left gap %d, right gap %d", left, right) | |||
| 612 | + | } | |||
| 613 | + | if focused != 1 { | |||
| 614 | + | t.Fatalf("%d focused cards", focused) | |||
| 615 | + | } | |||
| 616 | + | if len(s.Separators) != 4 { | |||
| 617 | + | t.Fatalf("separators %+v, want the 4 lines of the outline", s.Separators) | |||
| 618 | + | } | |||
| 619 | + | if s.Dim != 0.4 { | |||
| 620 | + | t.Fatalf("dim %v", s.Dim) | |||
| 621 | + | } | |||
| 622 | + | if s.TileInset != (ports.Insets{}) { | |||
| 623 | + | t.Fatalf("tile inset %+v", s.TileInset) | |||
| 624 | + | } | |||
| 625 | + | for _, w := range s.Windows { | |||
| 626 | + | if w.Preview == 0 && !w.Hidden { | |||
| 627 | + | t.Fatalf("window %d drawn under the cards", w.ID) | |||
| 628 | + | } | |||
| 629 | + | } | |||
| 630 | + | // The keyboard stays on the real focus. | |||
| 631 | + | if c.keyboardFocus() != focusedIDReal(c) { | |||
| 632 | + | t.Fatalf("keyboard focus %d moved", c.keyboardFocus()) | |||
| 633 | + | } | |||
| 634 | + | // A commit gives the workspace back. | |||
| 635 | + | sendKeys(t, c, superKey(0, false)) | |||
| 636 | + | if s := lastScene(t, c); len(scenePreviews(s)) != 0 { | |||
| 637 | + | t.Fatalf("cards after the commit: %+v", s.Windows) | |||
| 638 | + | } | |||
| 639 | + | } | |||
| 640 | + | ||||
| 641 | + | func focusedIDReal(c *Core) WindowID { | |||
| 642 | + | id, _ := c.cur().mon.Current().Focused() | |||
| 643 | + | return id | |||
| 644 | + | } | |||
| 645 | + | ||||
| 646 | + | func TestSwitcherLayoutHidesOtherWindowsOfColumns(t *testing.T) { | |||
| 647 | + | c, _ := switcherCore(t, 3) | |||
| 648 | + | c.cur().mon.AddWindow(4) | |||
| 649 | + | w := c.cur().mon.Current() | |||
| 650 | + | w.ConsumeOrExpel(-1) | |||
| 651 | + | if len(w.Columns) != 3 || w.columnWindows() != 4 { | |||
| 652 | + | t.Fatalf("setup: %d columns, %d windows", len(w.Columns), w.columnWindows()) | |||
| 653 | + | } | |||
| 654 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |||
| 655 | + | c.switcherTick() | |||
| 656 | + | s := indicatorScene(t, c) | |||
| 657 | + | reps := map[WindowID]bool{} | |||
| 658 | + | for _, id := range c.cur().mon.sw.order { | |||
| 659 | + | reps[id] = true | |||
| 660 | + | } | |||
| 661 | + | for _, sw := range s.Windows { | |||
| 662 | + | if reps[sw.ID] { | |||
| 663 | + | if sw.Preview == 0 || sw.Hidden { | |||
| 664 | + | t.Fatalf("representative %d not a card: %+v", sw.ID, sw) | |||
| 665 | + | } | |||
| 666 | + | } else if !sw.Hidden { | |||
| 667 | + | t.Fatalf("window %d not hidden: %+v", sw.ID, sw) | |||
| 668 | + | } | |||
| 669 | + | } | |||
| 670 | + | } | |||
| 671 | + | ||||
| 672 | + | func TestSwitcherCardsKeepKeysOnRealFocus(t *testing.T) { | |||
| 673 | + | c, _ := switcherCore(t, 3) | |||
| 674 | + | focus := switcherFocus(c) | |||
| 675 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |||
| 676 | + | c.switcherTick() | |||
| 677 | + | indicatorScene(t, c) | |||
| 678 | + | forwarded(c) | |||
| 679 | + | if c.keyboardFocus() != focus { | |||
| 680 | + | t.Fatalf("keyboard focus %d, want %d", c.keyboardFocus(), focus) | |||
| 681 | + | } | |||
| 682 | + | sendKeys(t, c, ports.KeyEvent{Keysym: "x", Keycode: 45, Mods: ports.ModSuper, Pressed: true}) | |||
| 683 | + | // An unbound key goes to the real focus (Cmd+x has no bind here). | |||
| 684 | + | fk := forwarded(c) | |||
| 685 | + | if len(fk) != 1 || fk[0].ID != focus || fk[0].Key.Keysym != "x" { | |||
| 686 | + | t.Fatalf("forwarded %+v, want x to %d", fk, focus) | |||
| 687 | + | } | |||
| 688 | + | } | |||
| 689 | + | ||||
| 690 | + | // --- P3.3: click --- | |||
| 691 | + | ||||
| 692 | + | func cardCenter(t *testing.T, c *Core, id WindowID) (float64, float64) { | |||
| 693 | + | t.Helper() | |||
| 694 | + | for _, p := range c.cur().shownLayout() { | |||
| 695 | + | if p.ID == id && p.Preview > 0 { | |||
| 696 | + | return float64(p.Rect.X + p.Rect.W/2), float64(p.Rect.Y + p.Rect.H/2) | |||
| 697 | + | } | |||
| 698 | + | } | |||
| 699 | + | t.Fatalf("no card for %d", id) | |||
| 700 | + | return 0, 0 | |||
| 701 | + | } | |||
| 702 | + | ||||
| 703 | + | func TestSwitcherClickOnCardCommits(t *testing.T) { | |||
| 704 | + | c, _ := switcherCore(t, 3) | |||
| 705 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |||
| 706 | + | c.switcherTick() | |||
| 707 | + | indicatorScene(t, c) | |||
| 708 | + | target := c.cur().mon.sw.order[2] | |||
| 709 | + | c.cursorX, c.cursorY = cardCenter(t, c, target) | |||
| 710 | + | forwarded(c) | |||
| 711 | + | if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil { | |||
| 712 | + | t.Fatal(err) | |||
| 713 | + | } | |||
| 714 | + | if c.cur().mon.sw.open || c.switcher.sc != nil || switcherFocus(c) != target { | |||
| 715 | + | t.Fatalf("open %v focus %d, want %d", c.cur().mon.sw.open, switcherFocus(c), target) | |||
| 716 | + | } | |||
| 717 | + | if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110}); err != nil { | |||
| 718 | + | t.Fatal(err) | |||
| 719 | + | } | |||
| 720 | + | ch := switcherCommands[c] | |||
| 721 | + | for len(ch) > 0 { | |||
| 722 | + | if v, ok := (<-ch).(ports.PointerButtonTo); ok { | |||
| 723 | + | t.Fatalf("click reached a client: %+v", v) | |||
| 724 | + | } | |||
| 725 | + | } | |||
| 726 | + | } | |||
| 727 | + | ||||
| 728 | + | func TestSwitcherClickElsewhereCancels(t *testing.T) { | |||
| 729 | + | c, _ := switcherCore(t, 3) | |||
| 730 | + | start := switcherFocus(c) | |||
| 731 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |||
| 732 | + | c.switcherTick() | |||
| 733 | + | indicatorScene(t, c) | |||
| 734 | + | c.cursorX, c.cursorY = 1, 1 | |||
| 735 | + | if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil { | |||
| 736 | + | t.Fatal(err) | |||
| 737 | + | } | |||
| 738 | + | if c.cur().mon.sw.open || c.switcher.sc != nil || switcherFocus(c) != start { | |||
| 739 | + | t.Fatalf("open %v focus %d, want %d", c.cur().mon.sw.open, switcherFocus(c), start) | |||
| 740 | + | } | |||
| 741 | + | } | |||
| 742 | + | ||||
| 743 | + | func TestSwitcherPressBeforeCardsCancelsAndContinues(t *testing.T) { | |||
| 744 | + | c, _ := switcherCore(t, 3) | |||
| 745 | + | start := switcherFocus(c) | |||
| 746 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |||
| 747 | + | handled, err := c.switcherClick(context.Background(), 0x110) | |||
| 748 | + | if err != nil || handled { | |||
| 749 | + | t.Fatalf("handled %v err %v", handled, err) | |||
| 750 | + | } | |||
| 751 | + | if c.cur().mon.sw.open || c.switcher.timerC != nil || switcherFocus(c) != start { | |||
| 752 | + | t.Fatal("press did not cancel") | |||
| 753 | + | } | |||
| 754 | + | } | |||
| 755 | + | ||||
| 756 | + | // --- P3.4: robustness --- | |||
| 757 | + | ||||
| 758 | + | func TestSwitcherClosingSelectedWindowKeepsSelectionValid(t *testing.T) { | |||
| 759 | + | c, _ := switcherCore(t, 3) | |||
| 760 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |||
| 761 | + | c.switcherTick() | |||
| 762 | + | sw := &c.cur().mon.sw | |||
| 763 | + | gone := sw.order[sw.at] | |||
| 764 | + | c.cur().mon.RemoveWindow(gone) | |||
| 765 | + | if !sw.open || len(sw.order) != 2 || sw.at < 0 || sw.at >= len(sw.order) || slices.Contains(sw.order, gone) { | |||
| 766 | + | t.Fatalf("open %v order %v at %d", sw.open, sw.order, sw.at) | |||
| 767 | + | } | |||
| 768 | + | s := indicatorScene(t, c) | |||
| 769 | + | if n := len(scenePreviews(s)); n != 2 { | |||
| 770 | + | t.Fatalf("%d cards after a close", n) | |||
| 771 | + | } | |||
| 772 | + | want := sw.order[sw.at] | |||
| 773 | + | sendKeys(t, c, superKey(0, false)) | |||
| 774 | + | if switcherFocus(c) != want { | |||
| 775 | + | t.Fatalf("focus %d, want neighbour %d", switcherFocus(c), want) | |||
| 776 | + | } | |||
| 777 | + | } | |||
| 778 | + | ||||
| 779 | + | func TestSwitcherClosingBeforeSelectionKeepsCard(t *testing.T) { | |||
| 780 | + | c, _ := switcherCore(t, 3) | |||
| 781 | + | c.mods = ports.ModSuper | |||
| 782 | + | c.runBind(context.Background(), ActionSwitchColumnPrev) | |||
| 783 | + | sw := &c.cur().mon.sw | |||
| 784 | + | selected := sw.order[sw.at] | |||
| 785 | + | c.cur().mon.RemoveWindow(sw.order[0]) | |||
| 786 | + | if !sw.open || sw.order[sw.at] != selected { | |||
| 787 | + | t.Fatalf("selection moved from %d: order %v at %d", selected, sw.order, sw.at) | |||
| 788 | + | } | |||
| 789 | + | } | |||
| 790 | + | ||||
| 791 | + | func TestSwitcherClosingDownToOneColumnClosesIt(t *testing.T) { | |||
| 792 | + | c, _ := switcherCore(t, 2) | |||
| 793 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |||
| 794 | + | c.switcherTick() | |||
| 795 | + | c.cur().mon.RemoveWindow(c.cur().mon.sw.order[1]) | |||
| 796 | + | if c.cur().mon.sw.open || c.cur().mon.sw.shown { | |||
| 797 | + | t.Fatal("switcher still open with one column") | |||
| 798 | + | } | |||
| 799 | + | if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 { | |||
| 800 | + | t.Fatalf("cards left: %+v", s.Windows) | |||
| 801 | + | } | |||
| 802 | + | // The timer fires on a closed switcher: harmless. | |||
| 803 | + | if c.switcherTick() { | |||
| 804 | + | t.Fatal("tick on a closed switcher") | |||
| 805 | + | } | |||
| 806 | + | sendKeys(t, c, superKey(0, false)) | |||
| 807 | + | } | |||
| 808 | + | ||||
| 809 | + | func TestSwitcherOutputRemovalCancels(t *testing.T) { | |||
| 810 | + | c, _ := switcherCore(t, 3) | |||
| 811 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |||
| 812 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |||
| 813 | + | c.switcherTick() | |||
| 814 | + | sc := c.switcher.sc | |||
| 815 | + | if sc == nil { | |||
| 816 | + | t.Fatal("no switcher screen") | |||
| 817 | + | } | |||
| 818 | + | c.removeScreen(sc.name()) | |||
| 819 | + | if c.switcher.sc != nil || c.switcher.timerC != nil || sc.mon.sw.open { | |||
| 820 | + | t.Fatal("switcher survived its output") | |||
| 821 | + | } | |||
| 822 | + | if err := c.publish(context.Background()); err != nil { | |||
| 823 | + | t.Fatal(err) | |||
| 824 | + | } | |||
| 825 | + | if c.switcherTick() { | |||
| 826 | + | t.Fatal("tick after the output went") | |||
| 827 | + | } | |||
| 828 | + | } | |||
| 829 | + | ||||
| 830 | + | func TestSwitcherFocusMovingToAnotherOutputReopensThere(t *testing.T) { | |||
| 831 | + | c, _ := switcherCore(t, 3) | |||
| 832 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |||
| 833 | + | first := c.cur() | |||
| 834 | + | for id := WindowID(11); id <= 13; id++ { | |||
| 835 | + | c.screens[c.screenIndex("B")].mon.AddWindow(id) | |||
| 836 | + | } | |||
| 837 | + | indicatorScene(t, c) | |||
| 838 | + | c.mods = ports.ModSuper | |||
| 839 | + | c.runBind(context.Background(), ActionSwitchColumnNext) | |||
| 840 | + | if c.switcher.sc != first { | |||
| 841 | + | t.Fatal("switcher not on the focused screen") | |||
| 842 | + | } | |||
| 843 | + | c.focusScreen = c.screenIndex("B") | |||
| 844 | + | c.runBind(context.Background(), ActionSwitchColumnNext) | |||
| 845 | + | if first.mon.sw.open { | |||
| 846 | + | t.Fatal("old switcher left open") | |||
| 847 | + | } | |||
| 848 | + | if c.switcher.sc != c.cur() || !c.cur().mon.sw.open || c.cur().mon.sw.at != 1 { | |||
| 849 | + | t.Fatalf("new switcher on %v open %v", c.switcher.sc == c.cur(), c.cur().mon.sw.open) | |||
| 850 | + | } | |||
| 851 | + | } | |||
| 852 | + | ||||
| 853 | + | // With animations on, the cards fly in and the commit flies them back; the | |||
| 854 | + | // windows end where the layout puts them, with no card left. | |||
| 855 | + | func TestSwitcherAnimatedHoldAndCommit(t *testing.T) { | |||
| 856 | + | c, ic := switcherCore(t, 3) | |||
| 857 | + | c.cfg.Animations.On = true | |||
| 858 | + | target := c.cur().mon.Current().switchOrder()[1] | |||
| 859 | + | sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true)) | |||
| 860 | + | c.switcherTick() | |||
| 861 | + | if err := c.publish(context.Background()); err != nil { | |||
| 862 | + | t.Fatal(err) | |||
| 863 | + | } | |||
| 864 | + | if len(scenePreviews(lastScene(t, c))) != 3 { | |||
| 865 | + | t.Fatal("cards not shown") | |||
| 866 | + | } | |||
| 867 | + | sendKeys(t, c, superKey(0, false)) | |||
| 868 | + | for range 200 { | |||
| 869 | + | ic.now = ic.now.Add(16 * time.Millisecond) | |||
| 870 | + | if err := c.step(context.Background(), nil); err != nil { | |||
| 871 | + | t.Fatal(err) | |||
| 872 | + | } | |||
| 873 | + | } | |||
| 874 | + | s := lastScene(t, c) | |||
| 875 | + | if len(scenePreviews(s)) != 0 || switcherFocus(c) != target { | |||
| 876 | + | t.Fatalf("cards %d, focus %d (want %d)", len(scenePreviews(s)), switcherFocus(c), target) | |||
| 877 | + | } | |||
| 878 | + | } | |||
M internal/core/transition.go +8 -8
| @@ -421,7 +421,7 @@ func (c *Core) mapWindow(v ports.WindowMapped) { | |||
| 421 | 421 | // peek. | |
| 422 | 422 | func (c *Core) appearMapped(id WindowID, now time.Time) { | |
| 423 | 423 | sc, w := c.screenOf(id) | |
| 424 | - | if sc == nil || c.security.Protected || sc.mon.ov.open || sc.mon.Current() != w { | |
| 424 | + | if sc == nil || c.security.Protected || sc.mon.previewing() || sc.mon.Current() != w { | |
| 425 | 425 | return | |
| 426 | 426 | } | |
| 427 | 427 | for _, p := range sc.mon.Layout() { | |
| @@ -451,7 +451,7 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) { | |||
| 451 | 451 | if sc == nil { | |
| 452 | 452 | return | |
| 453 | 453 | } | |
| 454 | - | if !c.animOn() || c.security.Protected || sc.mon.ov.open { | |
| 454 | + | if !c.animOn() || c.security.Protected || sc.mon.previewing() { | |
| 455 | 455 | sc.mon.RemoveWindow(v.ID) | |
| 456 | 456 | delete(sc.rects, v.ID) | |
| 457 | 457 | return | |
| @@ -619,8 +619,8 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||
| 619 | 619 | } | |
| 620 | 620 | s.sc = sc | |
| 621 | 621 | w := m.Current() | |
| 622 | - | s.ws, s.overview = w, m.ov.open | |
| 623 | - | if !m.ov.open { | |
| 622 | + | s.ws, s.overview = w, m.previewing() | |
| 623 | + | if !m.previewing() { | |
| 624 | 624 | // The overview does not scroll or slide: no camera. | |
| 625 | 625 | s.settledView, s.view = w.View, float64(w.View)+w.view.off | |
| 626 | 626 | s.switchAxis = w.policy().workspace | |
| @@ -655,11 +655,11 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||
| 655 | 655 | } | |
| 656 | 656 | r.veil = c.peekDim(p) | |
| 657 | 657 | s.rects = append(s.rects, r) | |
| 658 | - | if !m.ov.open && w.stashIndex(p.ID) >= 0 { | |
| 658 | + | if !m.previewing() && w.stashIndex(p.ID) >= 0 { | |
| 659 | 659 | s.stash = append(s.stash, p) | |
| 660 | 660 | } | |
| 661 | 661 | } | |
| 662 | - | if m.shown == nil && !m.ov.open { | |
| 662 | + | if m.shown == nil && !m.previewing() { | |
| 663 | 663 | // A landing slide measures in the list it began on. | |
| 664 | 664 | list := m.Workspaces | |
| 665 | 665 | if m.switchList != nil { | |
| @@ -704,7 +704,7 @@ func (c *Core) transition(before []viewShot, now time.Time) { | |||
| 704 | 704 | if b.ws == nil || !c.hasScreen(b.sc) || c.following(b.sc) { | |
| 705 | 705 | continue | |
| 706 | 706 | } | |
| 707 | - | if b.overview || b.sc.mon.ov.open { | |
| 707 | + | if b.overview || b.sc.mon.previewing() { | |
| 708 | 708 | c.transitionOverview(b, now) | |
| 709 | 709 | continue | |
| 710 | 710 | } | |
| @@ -896,7 +896,7 @@ func (c *Core) peekDim(p Placement) float64 { | |||
| 896 | 896 | // over (picking a card that still fades in does not pop it). | |
| 897 | 897 | func (c *Core) transitionOverview(b *viewShot, now time.Time) { | |
| 898 | 898 | sc := b.sc | |
| 899 | - | changed := b.overview != sc.mon.ov.open | |
| 899 | + | changed := b.overview != sc.mon.previewing() | |
| 900 | 900 | if changed { | |
| 901 | 901 | sc.stopRects() | |
| 902 | 902 | } | |
| @@ -421,7 +421,7 @@ func (c *Core) mapWindow(v ports.WindowMapped) { | |||||
| 421 | // peek. | 421 | // peek. | ||
| 422 | func (c *Core) appearMapped(id WindowID, now time.Time) { | 422 | func (c *Core) appearMapped(id WindowID, now time.Time) { | ||
| 423 | sc, w := c.screenOf(id) | 423 | sc, w := c.screenOf(id) | ||
| 424 | - | if sc == nil || c.security.Protected || sc.mon.ov.open || sc.mon.Current() != w { | 424 | + | if sc == nil || c.security.Protected || sc.mon.previewing() || sc.mon.Current() != w { |
| 425 | return | 425 | return | ||
| 426 | } | 426 | } | ||
| 427 | for _, p := range sc.mon.Layout() { | 427 | for _, p := range sc.mon.Layout() { | ||
| @@ -451,7 +451,7 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) { | |||||
| 451 | if sc == nil { | 451 | if sc == nil { | ||
| 452 | return | 452 | return | ||
| 453 | } | 453 | } | ||
| 454 | - | if !c.animOn() || c.security.Protected || sc.mon.ov.open { | 454 | + | if !c.animOn() || c.security.Protected || sc.mon.previewing() { |
| 455 | sc.mon.RemoveWindow(v.ID) | 455 | sc.mon.RemoveWindow(v.ID) | ||
| 456 | delete(sc.rects, v.ID) | 456 | delete(sc.rects, v.ID) | ||
| 457 | return | 457 | return | ||
| @@ -619,8 +619,8 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||||
| 619 | } | 619 | } | ||
| 620 | s.sc = sc | 620 | s.sc = sc | ||
| 621 | w := m.Current() | 621 | w := m.Current() | ||
| 622 | - | s.ws, s.overview = w, m.ov.open | 622 | + | s.ws, s.overview = w, m.previewing() |
| 623 | - | if !m.ov.open { | 623 | + | if !m.previewing() { |
| 624 | // The overview does not scroll or slide: no camera. | 624 | // The overview does not scroll or slide: no camera. | ||
| 625 | s.settledView, s.view = w.View, float64(w.View)+w.view.off | 625 | s.settledView, s.view = w.View, float64(w.View)+w.view.off | ||
| 626 | s.switchAxis = w.policy().workspace | 626 | s.switchAxis = w.policy().workspace | ||
| @@ -655,11 +655,11 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||||
| 655 | } | 655 | } | ||
| 656 | r.veil = c.peekDim(p) | 656 | r.veil = c.peekDim(p) | ||
| 657 | s.rects = append(s.rects, r) | 657 | s.rects = append(s.rects, r) | ||
| 658 | - | if !m.ov.open && w.stashIndex(p.ID) >= 0 { | 658 | + | if !m.previewing() && w.stashIndex(p.ID) >= 0 { |
| 659 | s.stash = append(s.stash, p) | 659 | s.stash = append(s.stash, p) | ||
| 660 | } | 660 | } | ||
| 661 | } | 661 | } | ||
| 662 | - | if m.shown == nil && !m.ov.open { | 662 | + | if m.shown == nil && !m.previewing() { |
| 663 | // A landing slide measures in the list it began on. | 663 | // A landing slide measures in the list it began on. | ||
| 664 | list := m.Workspaces | 664 | list := m.Workspaces | ||
| 665 | if m.switchList != nil { | 665 | if m.switchList != nil { | ||
| @@ -704,7 +704,7 @@ func (c *Core) transition(before []viewShot, now time.Time) { | |||||
| 704 | if b.ws == nil || !c.hasScreen(b.sc) || c.following(b.sc) { | 704 | if b.ws == nil || !c.hasScreen(b.sc) || c.following(b.sc) { | ||
| 705 | continue | 705 | continue | ||
| 706 | } | 706 | } | ||
| 707 | - | if b.overview || b.sc.mon.ov.open { | 707 | + | if b.overview || b.sc.mon.previewing() { |
| 708 | c.transitionOverview(b, now) | 708 | c.transitionOverview(b, now) | ||
| 709 | continue | 709 | continue | ||
| 710 | } | 710 | } | ||
| @@ -896,7 +896,7 @@ func (c *Core) peekDim(p Placement) float64 { | |||||
| 896 | // over (picking a card that still fades in does not pop it). | 896 | // over (picking a card that still fades in does not pop it). | ||
| 897 | func (c *Core) transitionOverview(b *viewShot, now time.Time) { | 897 | func (c *Core) transitionOverview(b *viewShot, now time.Time) { | ||
| 898 | sc := b.sc | 898 | sc := b.sc | ||
| 899 | - | changed := b.overview != sc.mon.ov.open | 899 | + | changed := b.overview != sc.mon.previewing() |
| 900 | if changed { | 900 | if changed { | ||
| 901 | sc.stopRects() | 901 | sc.stopRects() | ||
| 902 | } | 902 | } | ||