detached 1f8087b

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +1121 -24
M internal/core/action.go +4 -0
@@ -58,6 +58,10 @@ const (
5858 ActionMoveWorkspaceNext Action = "move-workspace-next"
5959 // Turn the focused tile into a free floating window, or back.
6060 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"
6165 )
6266
6367 // 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 by67 // 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 {
2020 }
2121
2222 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) {
2329 t.Helper()
2430 ic := &indicatorClock{now: time.Unix(1000, 0)}
2531 clock := portsmocks.NewMockClock(t)
@@ -39,7 +45,7 @@ func indicatorCore(t *testing.T) (*Core, *indicatorClock) {
3945 cfg.Keyboard.CmdKey = "super"
4046 cfg.Layout.MaxColumns = 2
4147 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})
4349 if err != nil {
4450 t.Fatal(err)
4551 }
@@ -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 = 246 cfg.Layout.MaxColumns = 2
41 cfg.Border.Width = 047 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) {
153153 return r, ports.CaptureReasonWorkspaceGone
154154 }
155155 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()
157157 return r, ports.CaptureReasonNone
158158 }
159159 i := c.screenIndex(t.output)
@@ -467,7 +467,7 @@ func (c *Core) captureSceneFor(sc *screen, v *capView) *ports.SceneCapture {
467467 return nil
468468 }
469469 want := ports.SceneCapture{}
470- if !sc.mon.ov.open {
470+ if !sc.mon.previewing() {
471471 want.Shown = sc.mon.Current().ID
472472 }
473473 if e := v.exclusion; e != nil {
@@ -153,7 +153,7 @@ func (c *Core) capResolve(t capTarget) (capResolved, ports.CaptureReason) {
153 return r, ports.CaptureReasonWorkspaceGone153 return r, ports.CaptureReasonWorkspaceGone
154 }154 }
155 r.sc, r.ws, r.rect = sc, w, w.Output155 r.sc, r.ws, r.rect = sc, w, w.Output
156- r.hidden = sc.mon.Current() != w || sc.mon.ov.open156+ r.hidden = sc.mon.Current() != w || sc.mon.previewing()
157 return r, ports.CaptureReasonNone157 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 nil467 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().ID471 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 {
152152 capt captureState
153153 // pulse marks a window that just got the focus (pulse.go).
154154 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+ }
155162 }
156163
157164 // publishScratch is where publishState and publishWorkspaces build their
@@ -263,7 +270,7 @@ func (c *Core) apply(cfg ports.Config) error {
263270 }
264271 switch Action(a) {
265272 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:
267274 default:
268275 return fmt.Errorf("invalid action %q", a)
269276 }
@@ -975,9 +982,9 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
975982 if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 {
976983 return id, px, py
977984 }
978- if sc.mon.ov.open {
985+ if sc.mon.previewing() {
979986 // Previews and the layers under them take no input: a click
980- // picks a preview (overviewClick).
987+ // picks a preview (overviewClick, switcherClick).
981988 return 0, 0, 0
982989 }
983990 if !sc.mon.frameHas(lx, ly) {
@@ -1017,6 +1024,7 @@ func (c *Core) Run(ctx context.Context) error {
10171024 defer c.stopFrame()
10181025 defer c.stopCaptureTimer()
10191026 defer c.stopPulseTimer()
1027+ defer c.cancelSwitcher()
10201028 // Startup commands run once per session. Full launcher queues retain a
10211029 // bounded remainder, selectable alongside owner events without polling.
10221030 c.startup = make([][]string, len(c.cfg.Startup))
@@ -1089,6 +1097,14 @@ func (c *Core) Run(ctx context.Context) error {
10891097 return nil
10901098 }
10911099 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
10921108 case <-c.capt.timerC:
10931109 c.capt.timerC, c.capt.timerStop = nil, nil
10941110 if c.captureFlashTick() && c.publish(ctx) != nil {
@@ -152,6 +152,13 @@ type Core struct {
152 capt captureState152 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 focusPulse154 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 their164 // 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, py983 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 click986 // 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, 0988 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 a1028 // 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 nil1097 return nil
1090 }1098 }
1091 continue1099 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, nil1109 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 {
7878 // not fullscreen, outside the overview.
7979 func (c *Core) draggable(id WindowID) (*screen, *Workspace, bool) {
8080 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 {
8282 return nil, nil, false
8383 }
8484 if w.floatIndex(id) < 0 && w.columnOf(id) < 0 {
@@ -288,7 +288,7 @@ func (c *Core) dropAt(id WindowID, x, y float64) dropTarget {
288288 m := sc.mon
289289 lx, ly := int(x)-sc.x, int(y)-sc.y
290290 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)) {
292292 return dropTarget{}
293293 }
294294 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, false82 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.mon288 m := sc.mon
289 lx, ly := int(x)-sc.x, int(y)-sc.y289 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 {
138138 // running (its end was lost) ends cancelled first: its slide settles back.
139139 func (c *Core) swipeBegin(b ports.SwipeBegin) bool {
140140 changed := false
141+ if c.switching() {
142+ c.cancelSwitcher()
143+ changed = true
144+ }
141145 if c.swipe != nil {
142146 c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: b.Time})
143147 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 := false140 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 = true147 changed = true
M internal/core/input.go +23 -0
@@ -128,6 +128,11 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
128128 }
129129 return nil
130130 }
131+ if v.Pressed && c.switching() {
132+ if handled, err := c.switcherClick(ctx, v.Button); err != nil || handled {
133+ return err
134+ }
135+ }
131136 if started, err := c.startDrag(ctx, v); err != nil || started {
132137 return err
133138 }
@@ -224,6 +229,18 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error {
224229
225230 func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
226231 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+ }
227244 if c.drag != nil && key.Pressed && key.Keysym == "Escape" {
228245 // Escape cancels the drag; its release goes nowhere either.
229246 c.cancelDrag()
@@ -308,6 +325,12 @@ func (c *Core) runBind(ctx context.Context, action Action) error {
308325 if action == ActionScaleUp || action == ActionScaleDown {
309326 return c.scaleBind(ctx, action)
310327 }
328+ if dir := switchDir(action); dir != 0 {
329+ return c.switchBind(ctx, dir)
330+ }
331+ if c.switching() {
332+ c.cancelSwitcher()
333+ }
311334 before := c.cur().mon.Current()
312335 swiped := c.swipedWorkspace()
313336 now := c.now()
@@ -128,6 +128,11 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
128 }128 }
129 return nil129 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 err137 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.Mods231 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) {
9090 // scroll overflow, one scrolled off by the user no longer does; nor does
9191 // one shown as an overview preview.
9292 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
9494 }
9595
9696 // 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 does90 // 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() != 093+ 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 is96 // 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) {
333333 w.RemoveWindow(id)
334334 m.normalize()
335335 }
336+ m.sw.remove(id)
336337 }
337338
338339 // SetFullscreen applies a client request, in place. A client request never
@@ -425,6 +426,9 @@ func (m *Monitor) Layout() []Placement { return m.layoutInto(nil) }
425426 // so a reused dst's previous result is overwritten and only a caller done
426427 // with it may pass it. It is never the monitor's own scratch.
427428 func (m *Monitor) layoutInto(dst []Placement) []Placement {
429+ if m.sw.shown && m.sw.ws == m.Current() {
430+ return m.switcherLayout(dst[:0])
431+ }
428432 if m.ov.open {
429433 return m.overviewLayout(dst[:0])
430434 }
@@ -447,7 +451,7 @@ func (m *Monitor) Output() Rect { return m.template.Output }
447451 // coordinates: its viewport, or the whole monitor in the overview. Slides
448452 // involving a sized workspace remain settled inside the current frame.
449453 func (m *Monitor) Frame() Rect {
450- if m.ov.open {
454+ if m.previewing() {
451455 return m.Output()
452456 }
453457 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 never339 // 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 done426 // 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. Slides451 // 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().Output457 return m.Current().Output
M internal/core/overview.go +2 -1
@@ -202,7 +202,8 @@ func overviewBlocks(a Action) bool {
202202 ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight,
203203 ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown,
204204 ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev,
205- ActionMoveWorkspaceNext, ActionToggleFloating:
205+ ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext,
206+ ActionSwitchColumnPrev:
206207 return true
207208 }
208209 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 true207 return true
207 }208 }
208 return false209 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
2222 // windows are going, and alone sizes the configures.
2323 layout, settled := sc.shown, sc.settledLayout
2424 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() {
2626 // Tiles stop at the panels; the overview lays out previews over
2727 // the whole output.
2828 u := view.usable
2929 scene.TileInset = ports.Insets{Top: u.Y, Left: u.X, Right: o.W - u.X - u.W, Bottom: o.H - u.Y - u.H}
3030 }
3131 var real map[WindowID]Placement
32- if sc.mon.ov.open {
32+ if sc.mon.previewing() {
3333 // Local to this screen's build: reused, cleared each time.
3434 if c.overviewReal == nil {
3535 c.overviewReal = make(map[WindowID]Placement)
@@ -44,7 +44,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
4444 }
4545 }
4646 scene.Dim = floatDim(layout, view, c.cfg.Floating.Dim)
47- if sc.mon.ov.open {
47+ if sc.mon.previewing() {
4848 // Darken the wallpaper around the previews.
4949 scene.Dim = c.cfg.Floating.Dim
5050 scene.DimBehind = scene.Dim > 0
@@ -56,6 +56,9 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
5656 // Frame the selection and separate numbered and named row groups.
5757 _, scene.Separators = sc.mon.overviewRows()
5858 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))
5962 } else {
6063 // Only the focused output lights the focused window's lines.
6164 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
8992 }
9093 continue
9194 }
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) {
9396 drawable = true
9497 if p.ID == c.pulse.id {
9598 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.settledLayout23 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 over26 // Tiles stop at the panels; the overview lays out previews over
27 // the whole output.27 // the whole output.
28 u := view.usable28 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]Placement31 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.Dim49 scene.Dim = c.cfg.Floating.Dim
50 scene.DimBehind = scene.Dim > 050 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 continue93 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 = true96 drawable = true
94 if p.ID == c.pulse.id {97 if p.ID == c.pulse.id {
95 sw.FocusEffect = pulse98 sw.FocusEffect = pulse
M internal/core/screens.go +4 -1
@@ -66,7 +66,7 @@ func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil }
6666 // rectsOwner is what the screen's rect motions belong to: its workspace on
6767 // screen, or nil in the overview, where the rows change under the cards.
6868 func (s *screen) rectsOwner() *Workspace {
69- if s.mon.ov.open {
69+ if s.mon.previewing() {
7070 return nil
7171 }
7272 return s.mon.Current()
@@ -334,6 +334,9 @@ func (c *Core) removeScreen(name string) {
334334 if i < 0 {
335335 return
336336 }
337+ if c.switcher.sc == c.screens[i] {
338+ c.cancelSwitcher()
339+ }
337340 // A capture session's off-screen workspace of this output goes with it.
338341 if c.configures.cw.sc == c.screens[i] {
339342 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 on66 // 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 nil70 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 return335 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 {
4646 c.gesture = clientGesture{}
4747 c.stopAnimations()
4848 c.stopPulse()
49+ c.cancelSwitcher()
50+ for _, sc := range c.screens {
51+ sc.mon.sw = switcherState{}
52+ }
4953 if state.Protected {
5054 c.dropCaptureSessions()
5155 }
@@ -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 @@
11 package core
22
33 import (
4+ "context"
5+ "math"
46 "slices"
7+ "time"
58 )
69
710 // The column switcher is an Alt+Tab for the tiled columns of the workspace on
@@ -12,6 +15,10 @@ import (
1215 // switcherRecentMax caps a workspace's recent list.
1316 const switcherRecentMax = 64
1417
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+
1522 // switcherState is the switcher of a monitor. A window stands for each
1623 // column in order, the focused column first. Nothing moves until the commit;
1724 // shown is set once the cards are drawn.
@@ -77,3 +84,276 @@ func (w *Workspace) switchOrder() []WindowID {
7784 }
7885 return order
7986 }
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 core1 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 on10 // 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 = 6416 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 each22 // 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 order85 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 @@
11 package core
22
33 import (
4+ "context"
45 "slices"
56 "testing"
7+ "time"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
611 )
712
813 // threeColumns is a workspace of three one-window columns, windows 1..3.
@@ -127,3 +132,747 @@ func TestMRUNoteFocusDoesNotAllocateWhenSettled(t *testing.T) {
127132 t.Fatalf("noteFocus allocates %v", n)
128133 }
129134 }
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 core1 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) {
421421 // peek.
422422 func (c *Core) appearMapped(id WindowID, now time.Time) {
423423 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 {
425425 return
426426 }
427427 for _, p := range sc.mon.Layout() {
@@ -451,7 +451,7 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) {
451451 if sc == nil {
452452 return
453453 }
454- if !c.animOn() || c.security.Protected || sc.mon.ov.open {
454+ if !c.animOn() || c.security.Protected || sc.mon.previewing() {
455455 sc.mon.RemoveWindow(v.ID)
456456 delete(sc.rects, v.ID)
457457 return
@@ -619,8 +619,8 @@ func (c *Core) snapshot(now time.Time) []viewShot {
619619 }
620620 s.sc = sc
621621 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() {
624624 // The overview does not scroll or slide: no camera.
625625 s.settledView, s.view = w.View, float64(w.View)+w.view.off
626626 s.switchAxis = w.policy().workspace
@@ -655,11 +655,11 @@ func (c *Core) snapshot(now time.Time) []viewShot {
655655 }
656656 r.veil = c.peekDim(p)
657657 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 {
659659 s.stash = append(s.stash, p)
660660 }
661661 }
662- if m.shown == nil && !m.ov.open {
662+ if m.shown == nil && !m.previewing() {
663663 // A landing slide measures in the list it began on.
664664 list := m.Workspaces
665665 if m.switchList != nil {
@@ -704,7 +704,7 @@ func (c *Core) transition(before []viewShot, now time.Time) {
704704 if b.ws == nil || !c.hasScreen(b.sc) || c.following(b.sc) {
705705 continue
706706 }
707- if b.overview || b.sc.mon.ov.open {
707+ if b.overview || b.sc.mon.previewing() {
708708 c.transitionOverview(b, now)
709709 continue
710710 }
@@ -896,7 +896,7 @@ func (c *Core) peekDim(p Placement) float64 {
896896 // over (picking a card that still fades in does not pop it).
897897 func (c *Core) transitionOverview(b *viewShot, now time.Time) {
898898 sc := b.sc
899- changed := b.overview != sc.mon.ov.open
899+ changed := b.overview != sc.mon.previewing()
900900 if changed {
901901 sc.stopRects()
902902 }
@@ -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 return425 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 return452 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 return457 return
@@ -619,8 +619,8 @@ func (c *Core) snapshot(now time.Time) []viewShot {
619 }619 }
620 s.sc = sc620 s.sc = sc
621 w := m.Current()621 w := m.Current()
622- s.ws, s.overview = w, m.ov.open622+ 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.off625 s.settledView, s.view = w.View, float64(w.View)+w.view.off
626 s.switchAxis = w.policy().workspace626 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.Workspaces664 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 continue705 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 continue709 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.sc898 sc := b.sc
899- changed := b.overview != sc.mon.ov.open899+ changed := b.overview != sc.mon.previewing()
900 if changed {900 if changed {
901 sc.stopRects()901 sc.stopRects()
902 }902 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close