detached 17b575a

fix(core): keep the switcher in sync with the workspace on screen

- The cards count as shown only while their workspace is on screen
  (Monitor.switcherShown, used by previewing and layoutInto). An
  activation, a taskbar click or an output coming back no longer leaves
  the screen dimmed and unclickable; the next step or publish drops the
  stale switcher and the next Cmd+Tab opens fresh.
- Ignore switch-column-* during a window drag.
- Snapshot before another bind closes the cards so they fly back.
- Closing the selected card's window keeps its column's card, standing
  for the column's new focused window; the card goes with the column.
- Committing focuses the tile even when a float or the stash holds the
  focus, and moves the keyboard to the switcher's output.
- security.go: drop the per-screen reset cancelSwitcher already covers.
- Tests: no package-level command map, checked runBind errors, F9 bind
  through keyEvent, doc comment dedup.
commit
17b575a1546e5e722ffada606487178d2b22e72d
author
brice · · 2026-10-08 18:10 UTC
parent
c947263
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +369 -88
M internal/core/core.go +4 -0
@@ -598,6 +598,10 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
598598 if c.security.Protected {
599599 return c.publishProtected(ctx)
600600 }
601+ if c.switching() && c.switcherStale() {
602+ // Another workspace came on screen under the switcher: it is over.
603+ c.cancelSwitcher()
604+ }
601605 for _, sc := range c.screens {
602606 // The overview's selection is provisional: it focuses nothing yet.
603607 if !sc.mon.ov.open {
@@ -598,6 +598,10 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
598 if c.security.Protected {598 if c.security.Protected {
599 return c.publishProtected(ctx)599 return c.publishProtected(ctx)
600 }600 }
601+ if c.switching() && c.switcherStale() {
602+ // Another workspace came on screen under the switcher: it is over.
603+ c.cancelSwitcher()
604+ }
601 for _, sc := range c.screens {605 for _, sc := range c.screens {
602 // The overview's selection is provisional: it focuses nothing yet.606 // The overview's selection is provisional: it focuses nothing yet.
603 if !sc.mon.ov.open {607 if !sc.mon.ov.open {
M internal/core/input.go +4 -3
@@ -328,13 +328,14 @@ func (c *Core) runBind(ctx context.Context, action Action) error {
328328 if dir := switchDir(action); dir != 0 {
329329 return c.switchBind(ctx, dir)
330330 }
331- if c.switching() {
332- c.cancelSwitcher()
333- }
334331 before := c.cur().mon.Current()
335332 swiped := c.swipedWorkspace()
336333 now := c.now()
334+ // Snapshot before the switcher closes: its cards fly back to the layout.
337335 shots := c.snapshot(now)
336+ if c.switching() {
337+ c.cancelSwitcher()
338+ }
338339 c.keyboard.takeBack() // a bind acts on the windows
339340 effect := c.applyAction(action)
340341 if effect.Quit {
@@ -328,13 +328,14 @@ func (c *Core) runBind(ctx context.Context, action Action) error {
328 if dir := switchDir(action); dir != 0 {328 if dir := switchDir(action); dir != 0 {
329 return c.switchBind(ctx, dir)329 return c.switchBind(ctx, dir)
330 }330 }
331- if c.switching() {
332- c.cancelSwitcher()
333- }
334 before := c.cur().mon.Current()331 before := c.cur().mon.Current()
335 swiped := c.swipedWorkspace()332 swiped := c.swipedWorkspace()
336 now := c.now()333 now := c.now()
334+ // Snapshot before the switcher closes: its cards fly back to the layout.
337 shots := c.snapshot(now)335 shots := c.snapshot(now)
336+ if c.switching() {
337+ c.cancelSwitcher()
338+ }
338 c.keyboard.takeBack() // a bind acts on the windows339 c.keyboard.takeBack() // a bind acts on the windows
339 effect := c.applyAction(action)340 effect := c.applyAction(action)
340 if effect.Quit {341 if effect.Quit {
M internal/core/monitor.go +3 -2
@@ -329,11 +329,12 @@ func (m *Monitor) AddFloating(id WindowID, width, height int) {
329329 // RemoveWindow drops the window wherever it is; focus stays on the active
330330 // workspace.
331331 func (m *Monitor) RemoveWindow(id WindowID) {
332+ heir := m.sw.heir(id)
332333 if w, _ := m.find(id); w != nil {
333334 w.RemoveWindow(id)
334335 m.normalize()
335336 }
336- m.sw.remove(id)
337+ m.sw.remove(id, heir)
337338 }
338339
339340 // SetFullscreen applies a client request, in place. A client request never
@@ -426,7 +427,7 @@ func (m *Monitor) Layout() []Placement { return m.layoutInto(nil) }
426427 // so a reused dst's previous result is overwritten and only a caller done
427428 // with it may pass it. It is never the monitor's own scratch.
428429 func (m *Monitor) layoutInto(dst []Placement) []Placement {
429- if m.sw.shown && m.sw.ws == m.Current() {
430+ if m.switcherShown() {
430431 return m.switcherLayout(dst[:0])
431432 }
432433 if m.ov.open {
@@ -329,11 +329,12 @@ func (m *Monitor) AddFloating(id WindowID, width, height int) {
329 // RemoveWindow drops the window wherever it is; focus stays on the active329 // RemoveWindow drops the window wherever it is; focus stays on the active
330 // workspace.330 // workspace.
331 func (m *Monitor) RemoveWindow(id WindowID) {331 func (m *Monitor) RemoveWindow(id WindowID) {
332+ heir := m.sw.heir(id)
332 if w, _ := m.find(id); w != nil {333 if w, _ := m.find(id); w != nil {
333 w.RemoveWindow(id)334 w.RemoveWindow(id)
334 m.normalize()335 m.normalize()
335 }336 }
336- m.sw.remove(id)337+ m.sw.remove(id, heir)
337 }338 }
338 339
339 // SetFullscreen applies a client request, in place. A client request never340 // SetFullscreen applies a client request, in place. A client request never
@@ -426,7 +427,7 @@ func (m *Monitor) Layout() []Placement { return m.layoutInto(nil) }
426 // so a reused dst's previous result is overwritten and only a caller done427 // so a reused dst's previous result is overwritten and only a caller done
427 // with it may pass it. It is never the monitor's own scratch.428 // with it may pass it. It is never the monitor's own scratch.
428 func (m *Monitor) layoutInto(dst []Placement) []Placement {429 func (m *Monitor) layoutInto(dst []Placement) []Placement {
429- if m.sw.shown && m.sw.ws == m.Current() {430+ if m.switcherShown() {
430 return m.switcherLayout(dst[:0])431 return m.switcherLayout(dst[:0])
431 }432 }
432 if m.ov.open {433 if m.ov.open {
M internal/core/scene.go +1 -1
@@ -56,7 +56,7 @@ 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 {
59+ } else if sc.mon.switcherShown() {
6060 // The switcher's cards: only the selection is framed.
6161 scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2))
6262 } else {
@@ -56,7 +56,7 @@ 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 {59+ } else if sc.mon.switcherShown() {
60 // The switcher's cards: only the selection is framed.60 // The switcher's cards: only the selection is framed.
61 scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2))61 scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2))
62 } else {62 } else {
M internal/core/security.go +0 -3
@@ -47,9 +47,6 @@ func (c *Core) syncSecurity() bool {
4747 c.stopAnimations()
4848 c.stopPulse()
4949 c.cancelSwitcher()
50- for _, sc := range c.screens {
51- sc.mon.sw = switcherState{}
52- }
5350 if state.Protected {
5451 c.dropCaptureSessions()
5552 }
@@ -47,9 +47,6 @@ func (c *Core) syncSecurity() bool {
47 c.stopAnimations()47 c.stopAnimations()
48 c.stopPulse()48 c.stopPulse()
49 c.cancelSwitcher()49 c.cancelSwitcher()
50- for _, sc := range c.screens {
51- sc.mon.sw = switcherState{}
52- }
53 if state.Protected {50 if state.Protected {
54 c.dropCaptureSessions()51 c.dropCaptureSessions()
55 }52 }
M internal/core/switcher.go +75 -21
@@ -31,9 +31,15 @@ type switcherState struct {
3131 sizes map[WindowID]Rect
3232 }
3333
34+// switcherShown reports whether the switcher's cards are drawn. They belong
35+// to the workspace that was on screen when they showed: if another one is on
36+// screen now (an activation, the taskbar, an output coming back) the cards
37+// are stale and the monitor draws its workspace again.
38+func (m *Monitor) switcherShown() bool { return m.sw.shown && m.sw.ws == m.Current() }
39+
3440 // previewing reports whether the monitor draws previews instead of its
3541 // workspace: the overview, or the switcher's cards.
36-func (m *Monitor) previewing() bool { return m.ov.open || m.sw.shown }
42+func (m *Monitor) previewing() bool { return m.ov.open || m.switcherShown() }
3743
3844 // noteFocus records the focused column as the most recently used. It does
3945 // nothing while a float or the stash has the focus, and allocates nothing
@@ -85,10 +91,28 @@ func (w *Workspace) switchOrder() []WindowID {
8591 return order
8692 }
8793
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) {
94+// heir is the window of id's column that is not id, or 0: call it before id
95+// is removed, then hand it to remove.
96+func (s *switcherState) heir(id WindowID) WindowID {
97+ if !s.open || s.ws == nil || !slices.Contains(s.order, id) {
98+ return 0
99+ }
100+ if i := s.ws.columnOf(id); i >= 0 {
101+ for _, v := range s.ws.Columns[i].Windows {
102+ if v != id {
103+ return v
104+ }
105+ }
106+ }
107+ return 0
108+}
109+
110+// remove drops a closed window from the open switcher. When its column
111+// still has windows (heir, from before the removal) the card stands for the
112+// column's new focused window and the selection stays; otherwise the card
113+// goes, and the selection stays on the same card (or the one that took its
114+// place). With fewer than two columns left there is nothing to switch.
115+func (s *switcherState) remove(id, heir WindowID) {
92116 if !s.open {
93117 return
94118 }
@@ -96,6 +120,17 @@ func (s *switcherState) remove(id WindowID) {
96120 if i < 0 {
97121 return
98122 }
123+ if j := s.ws.columnOf(heir); heir != 0 && j >= 0 {
124+ col := s.ws.Columns[j]
125+ next := col.Windows[col.Focus]
126+ if size, ok := s.sizes[id]; ok {
127+ if _, has := s.sizes[next]; !has {
128+ s.sizes[next] = size
129+ }
130+ }
131+ s.order[i] = next
132+ return
133+ }
99134 s.order = slices.Delete(s.order, i, i+1)
100135 if i < s.at {
101136 s.at--
@@ -109,26 +144,32 @@ func (s *switcherState) remove(id WindowID) {
109144 // commitSwitcher closes the switcher and focuses the selected column. The
110145 // column left hands its maximization to the target. It does nothing when the
111146 // workspace is not on screen any more, the window went, or the selection is
112-// the column already focused.
113-func (m *Monitor) commitSwitcher() {
147+// the tile already focused (a float or the stash holding the focus does not
148+// count: the tile takes it back). It reports whether the selection was still
149+// valid on the workspace on screen.
150+func (m *Monitor) commitSwitcher() bool {
114151 s := m.sw
115152 m.sw = switcherState{}
116153 if !s.open || s.ws == nil || s.at < 0 || s.at >= len(s.order) {
117- return
154+ return false
118155 }
119156 id, w := s.order[s.at], s.ws
120157 if w != m.Current() {
121- return
158+ return false
122159 }
123160 i := w.columnOf(id)
124- if i < 0 || i == w.Focus {
125- return
161+ if i < 0 {
162+ return false
163+ }
164+ if i == w.Focus && !w.floatFocus && !w.stashFocused() {
165+ return true
126166 }
127- if w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth {
167+ if i != w.Focus && w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth {
128168 w.transferMaximization(i)
129169 }
130170 // Activate leaves another window's fullscreen and handles dialogs.
131171 w.Activate(id)
172+ return true
132173 }
133174
134175 // switchDir is the direction of a switch action, 0 for any other action.
@@ -147,6 +188,13 @@ func (c *Core) switching() bool {
147188 return c.switcher.sc != nil && c.switcher.sc.mon.sw.open
148189 }
149190
191+// switcherStale reports whether the open switcher belongs to a workspace
192+// that is not on its screen any more.
193+func (c *Core) switcherStale() bool {
194+ m := c.switcher.sc.mon
195+ return m.sw.open && m.sw.ws != m.Current()
196+}
197+
150198 func (c *Core) stopSwitcherTimer() {
151199 if c.switcher.timerStop != nil {
152200 c.switcher.timerStop()
@@ -164,11 +212,17 @@ func (c *Core) cancelSwitcher() {
164212 c.switcher.sc = nil
165213 }
166214
167-// commitSwitcher commits the switcher on its own screen and ends it.
215+// commitSwitcher commits the switcher on its own screen and ends it. The
216+// keyboard follows the committed window, even if the pointer moved the focus
217+// to another output meanwhile.
168218 func (c *Core) commitSwitcher() {
169219 c.stopSwitcherTimer()
170220 if sc := c.switcher.sc; sc != nil {
171- sc.mon.commitSwitcher()
221+ if sc.mon.commitSwitcher() {
222+ if i := slices.Index(c.screens, sc); i >= 0 {
223+ c.focusScreen = i
224+ }
225+ }
172226 }
173227 c.switcher.sc = nil
174228 }
@@ -179,11 +233,12 @@ func (c *Core) commitSwitcher() {
179233 // at once.
180234 func (c *Core) switchStep(dir int) {
181235 sc := c.cur()
182- if c.switching() && c.switcher.sc != sc {
183- // The focus moved to another output: start over there.
236+ if c.switching() && (c.switcher.sc != sc || c.switcherStale()) {
237+ // The focus moved to another output, or another workspace came on
238+ // screen: start over.
184239 c.cancelSwitcher()
185240 }
186- if sc.mon.ov.open {
241+ if sc.mon.ov.open || c.drag != nil {
187242 return
188243 }
189244 m := sc.mon
@@ -314,10 +369,9 @@ func (c *Core) switcherClick(ctx context.Context, button uint32) (handled bool,
314369
315370 // switcherLayout lays out the shown switcher: one card per column in
316371 // 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.
372+// window scaled into its box (never up). Like the overview, the selected
373+// card gets Focused (so its configure is Activated) while the keyboard stays
374+// on the real focus. Every other window is hidden.
321375 func (m *Monitor) switcherLayout(dst []Placement) []Placement {
322376 sw := &m.sw
323377 w := sw.ws
@@ -31,9 +31,15 @@ type switcherState struct {
31 sizes map[WindowID]Rect31 sizes map[WindowID]Rect
32 }32 }
33 33
34+// switcherShown reports whether the switcher's cards are drawn. They belong
35+// to the workspace that was on screen when they showed: if another one is on
36+// screen now (an activation, the taskbar, an output coming back) the cards
37+// are stale and the monitor draws its workspace again.
38+func (m *Monitor) switcherShown() bool { return m.sw.shown && m.sw.ws == m.Current() }
39+
34 // previewing reports whether the monitor draws previews instead of its40 // previewing reports whether the monitor draws previews instead of its
35 // workspace: the overview, or the switcher's cards.41 // workspace: the overview, or the switcher's cards.
36-func (m *Monitor) previewing() bool { return m.ov.open || m.sw.shown }42+func (m *Monitor) previewing() bool { return m.ov.open || m.switcherShown() }
37 43
38 // noteFocus records the focused column as the most recently used. It does44 // noteFocus records the focused column as the most recently used. It does
39 // nothing while a float or the stash has the focus, and allocates nothing45 // nothing while a float or the stash has the focus, and allocates nothing
@@ -85,10 +91,28 @@ func (w *Workspace) switchOrder() []WindowID {
85 return order91 return order
86 }92 }
87 93
88-// remove drops a closed window from the open switcher: its card goes, the94+// heir is the window of id's column that is not id, or 0: call it before id
89-// selection stays on the same card (or the one that took its place). With95+// is removed, then hand it to remove.
90-// fewer than two columns left there is nothing to switch.96+func (s *switcherState) heir(id WindowID) WindowID {
91-func (s *switcherState) remove(id WindowID) {97+ if !s.open || s.ws == nil || !slices.Contains(s.order, id) {
98+ return 0
99+ }
100+ if i := s.ws.columnOf(id); i >= 0 {
101+ for _, v := range s.ws.Columns[i].Windows {
102+ if v != id {
103+ return v
104+ }
105+ }
106+ }
107+ return 0
108+}
109+
110+// remove drops a closed window from the open switcher. When its column
111+// still has windows (heir, from before the removal) the card stands for the
112+// column's new focused window and the selection stays; otherwise the card
113+// goes, and the selection stays on the same card (or the one that took its
114+// place). With fewer than two columns left there is nothing to switch.
115+func (s *switcherState) remove(id, heir WindowID) {
92 if !s.open {116 if !s.open {
93 return117 return
94 }118 }
@@ -96,6 +120,17 @@ func (s *switcherState) remove(id WindowID) {
96 if i < 0 {120 if i < 0 {
97 return121 return
98 }122 }
123+ if j := s.ws.columnOf(heir); heir != 0 && j >= 0 {
124+ col := s.ws.Columns[j]
125+ next := col.Windows[col.Focus]
126+ if size, ok := s.sizes[id]; ok {
127+ if _, has := s.sizes[next]; !has {
128+ s.sizes[next] = size
129+ }
130+ }
131+ s.order[i] = next
132+ return
133+ }
99 s.order = slices.Delete(s.order, i, i+1)134 s.order = slices.Delete(s.order, i, i+1)
100 if i < s.at {135 if i < s.at {
101 s.at--136 s.at--
@@ -109,26 +144,32 @@ func (s *switcherState) remove(id WindowID) {
109 // commitSwitcher closes the switcher and focuses the selected column. The144 // commitSwitcher closes the switcher and focuses the selected column. The
110 // column left hands its maximization to the target. It does nothing when the145 // 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 is146 // workspace is not on screen any more, the window went, or the selection is
112-// the column already focused.147+// the tile already focused (a float or the stash holding the focus does not
113-func (m *Monitor) commitSwitcher() {148+// count: the tile takes it back). It reports whether the selection was still
149+// valid on the workspace on screen.
150+func (m *Monitor) commitSwitcher() bool {
114 s := m.sw151 s := m.sw
115 m.sw = switcherState{}152 m.sw = switcherState{}
116 if !s.open || s.ws == nil || s.at < 0 || s.at >= len(s.order) {153 if !s.open || s.ws == nil || s.at < 0 || s.at >= len(s.order) {
117- return154+ return false
118 }155 }
119 id, w := s.order[s.at], s.ws156 id, w := s.order[s.at], s.ws
120 if w != m.Current() {157 if w != m.Current() {
121- return158+ return false
122 }159 }
123 i := w.columnOf(id)160 i := w.columnOf(id)
124- if i < 0 || i == w.Focus {161+ if i < 0 {
125- return162+ return false
163+ }
164+ if i == w.Focus && !w.floatFocus && !w.stashFocused() {
165+ return true
126 }166 }
127- if w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth {167+ if i != w.Focus && w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth {
128 w.transferMaximization(i)168 w.transferMaximization(i)
129 }169 }
130 // Activate leaves another window's fullscreen and handles dialogs.170 // Activate leaves another window's fullscreen and handles dialogs.
131 w.Activate(id)171 w.Activate(id)
172+ return true
132 }173 }
133 174
134 // switchDir is the direction of a switch action, 0 for any other action.175 // switchDir is the direction of a switch action, 0 for any other action.
@@ -147,6 +188,13 @@ func (c *Core) switching() bool {
147 return c.switcher.sc != nil && c.switcher.sc.mon.sw.open188 return c.switcher.sc != nil && c.switcher.sc.mon.sw.open
148 }189 }
149 190
191+// switcherStale reports whether the open switcher belongs to a workspace
192+// that is not on its screen any more.
193+func (c *Core) switcherStale() bool {
194+ m := c.switcher.sc.mon
195+ return m.sw.open && m.sw.ws != m.Current()
196+}
197+
150 func (c *Core) stopSwitcherTimer() {198 func (c *Core) stopSwitcherTimer() {
151 if c.switcher.timerStop != nil {199 if c.switcher.timerStop != nil {
152 c.switcher.timerStop()200 c.switcher.timerStop()
@@ -164,11 +212,17 @@ func (c *Core) cancelSwitcher() {
164 c.switcher.sc = nil212 c.switcher.sc = nil
165 }213 }
166 214
167-// commitSwitcher commits the switcher on its own screen and ends it.215+// commitSwitcher commits the switcher on its own screen and ends it. The
216+// keyboard follows the committed window, even if the pointer moved the focus
217+// to another output meanwhile.
168 func (c *Core) commitSwitcher() {218 func (c *Core) commitSwitcher() {
169 c.stopSwitcherTimer()219 c.stopSwitcherTimer()
170 if sc := c.switcher.sc; sc != nil {220 if sc := c.switcher.sc; sc != nil {
171- sc.mon.commitSwitcher()221+ if sc.mon.commitSwitcher() {
222+ if i := slices.Index(c.screens, sc); i >= 0 {
223+ c.focusScreen = i
224+ }
225+ }
172 }226 }
173 c.switcher.sc = nil227 c.switcher.sc = nil
174 }228 }
@@ -179,11 +233,12 @@ func (c *Core) commitSwitcher() {
179 // at once.233 // at once.
180 func (c *Core) switchStep(dir int) {234 func (c *Core) switchStep(dir int) {
181 sc := c.cur()235 sc := c.cur()
182- if c.switching() && c.switcher.sc != sc {236+ if c.switching() && (c.switcher.sc != sc || c.switcherStale()) {
183- // The focus moved to another output: start over there.237+ // The focus moved to another output, or another workspace came on
238+ // screen: start over.
184 c.cancelSwitcher()239 c.cancelSwitcher()
185 }240 }
186- if sc.mon.ov.open {241+ if sc.mon.ov.open || c.drag != nil {
187 return242 return
188 }243 }
189 m := sc.mon244 m := sc.mon
@@ -314,10 +369,9 @@ func (c *Core) switcherClick(ctx context.Context, button uint32) (handled bool,
314 369
315 // switcherLayout lays out the shown switcher: one card per column in370 // switcherLayout lays out the shown switcher: one card per column in
316 // switchOrder, equal boxes in a row centered in the overview area, every371 // 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.372+// window scaled into its box (never up). Like the overview, the selected
318-// Like the overview, the selected card gets Focused (so its configure is373+// card gets Focused (so its configure is Activated) while the keyboard stays
319-// Activated) while the keyboard stays on the real focus. Every other window374+// on the real focus. Every other window is hidden.
320-// is hidden.
321 func (m *Monitor) switcherLayout(dst []Placement) []Placement {375 func (m *Monitor) switcherLayout(dst []Placement) []Placement {
322 sw := &m.sw376 sw := &m.sw
323 w := sw.ws377 w := sw.ws
M internal/core/switcher_test.go +282 -58
@@ -149,12 +149,10 @@ const (
149149 // switcherCore is indicatorCore with the switcher binds, up to three
150150 // columns on screen and n one-window columns (windows 1..n, n focused),
151151 // published once so the configures carry the windows' sizes.
152-func switcherCore(t *testing.T, n int) (*Core, *indicatorClock) {
152+func switcherCore(t *testing.T, n int) (*Core, *indicatorClock, <-chan ports.ClientCommand) {
153153 t.Helper()
154154 commands := make(chan ports.ClientCommand, 64)
155155 c, ic := indicatorCoreWith(t, commands)
156- switcherCommands[c] = commands
157- t.Cleanup(func() { delete(switcherCommands, c) })
158156 cfg := c.cfg
159157 cfg.Layout.MaxColumns = 3
160158 cfg.Binds = switcherBinds
@@ -165,7 +163,7 @@ func switcherCore(t *testing.T, n int) (*Core, *indicatorClock) {
165163 c.cur().mon.AddWindow(id)
166164 }
167165 indicatorScene(t, c)
168- return c, ic
166+ return c, ic, commands
169167 }
170168
171169 func tab(mods ports.Mods, pressed bool) ports.KeyEvent {
@@ -180,6 +178,13 @@ func superKey(mods ports.Mods, pressed bool) ports.KeyEvent {
180178 return ports.KeyEvent{Keysym: "Super_L", Keycode: keySuper, Mods: mods, Pressed: pressed}
181179 }
182180
181+func bind(t *testing.T, c *Core, a Action) {
182+ t.Helper()
183+ if err := c.runBind(context.Background(), a); err != nil {
184+ t.Fatal(err)
185+ }
186+}
187+
183188 func sendKeys(t *testing.T, c *Core, keys ...ports.KeyEvent) {
184189 t.Helper()
185190 for _, k := range keys {
@@ -207,13 +212,9 @@ func switcherFocus(c *Core) WindowID {
207212 return id
208213 }
209214
210-// switcherCommands is the receiving end of each switcherCore's commands.
211-var switcherCommands = map[*Core]chan ports.ClientCommand{}
212-
213215 // forwarded drains the commands and returns the forwarded keys.
214-func forwarded(c *Core) []ports.ForwardKey {
216+func forwarded(ch <-chan ports.ClientCommand) []ports.ForwardKey {
215217 var out []ports.ForwardKey
216- ch := switcherCommands[c]
217218 for len(ch) > 0 {
218219 if v, ok := (<-ch).(ports.ForwardKey); ok {
219220 out = append(out, v)
@@ -321,7 +322,7 @@ func TestSwitchCommitIgnoresGoneWindowAndOtherWorkspace(t *testing.T) {
321322 // --- P2.2: actions and step ---
322323
323324 func TestSwitchStepOpensOnPreviousColumnAndCycles(t *testing.T) {
324- c, ic := switcherCore(t, 3)
325+ c, ic, _ := switcherCore(t, 3)
325326 c.mods = ports.ModSuper
326327 before := switcherFocus(c)
327328 if err := c.runBind(context.Background(), ActionSwitchColumnNext); err != nil {
@@ -334,19 +335,19 @@ func TestSwitchStepOpensOnPreviousColumnAndCycles(t *testing.T) {
334335 if len(ic.timers) != 1 || ic.timers[0] != switcherDelay {
335336 t.Fatalf("timers %v, want one of %v", ic.timers, switcherDelay)
336337 }
337- c.runBind(context.Background(), ActionSwitchColumnNext)
338+ bind(t, c, ActionSwitchColumnNext)
338339 if sw.at != 2 {
339340 t.Fatalf("at %d", sw.at)
340341 }
341- c.runBind(context.Background(), ActionSwitchColumnNext)
342+ bind(t, c, ActionSwitchColumnNext)
342343 if sw.at != 0 {
343344 t.Fatalf("at %d, want wrap to 0", sw.at)
344345 }
345- c.runBind(context.Background(), ActionSwitchColumnPrev)
346+ bind(t, c, ActionSwitchColumnPrev)
346347 if sw.at != 2 {
347348 t.Fatalf("at %d, want wrap to 2", sw.at)
348349 }
349- c.runBind(context.Background(), ActionSwitchColumnPrev)
350+ bind(t, c, ActionSwitchColumnPrev)
350351 if sw.at != 1 {
351352 t.Fatalf("at %d", sw.at)
352353 }
@@ -356,16 +357,16 @@ func TestSwitchStepOpensOnPreviousColumnAndCycles(t *testing.T) {
356357 }
357358
358359 func TestSwitchPrevOpensOnOldestColumn(t *testing.T) {
359- c, _ := switcherCore(t, 3)
360+ c, _, _ := switcherCore(t, 3)
360361 c.mods = ports.ModSuper
361- c.runBind(context.Background(), ActionSwitchColumnPrev)
362+ bind(t, c, ActionSwitchColumnPrev)
362363 if sw := c.cur().mon.sw; !sw.open || sw.at != len(sw.order)-1 {
363364 t.Fatalf("at %d of %d", sw.at, len(sw.order))
364365 }
365366 }
366367
367368 func TestSwitchWithoutCmdCommitsAtOnce(t *testing.T) {
368- c, ic := switcherCore(t, 3)
369+ c, ic, _ := switcherCore(t, 3)
369370 c.mods = 0
370371 start := switcherFocus(c)
371372 if err := c.runBind(context.Background(), ActionSwitchColumnNext); err != nil {
@@ -377,23 +378,23 @@ func TestSwitchWithoutCmdCommitsAtOnce(t *testing.T) {
377378 if switcherFocus(c) == start {
378379 t.Fatal("focus did not move")
379380 }
380- c.runBind(context.Background(), ActionSwitchColumnNext)
381+ bind(t, c, ActionSwitchColumnNext)
381382 if switcherFocus(c) != start {
382383 t.Fatalf("focus %d, want back on %d", switcherFocus(c), start)
383384 }
384385 }
385386
386387 func TestSwitchIgnoredWithOneColumnOrOverview(t *testing.T) {
387- c, _ := switcherCore(t, 1)
388+ c, _, _ := switcherCore(t, 1)
388389 c.mods = ports.ModSuper
389- c.runBind(context.Background(), ActionSwitchColumnNext)
390+ bind(t, c, ActionSwitchColumnNext)
390391 if c.cur().mon.sw.open || c.switcher.sc != nil {
391392 t.Fatal("switcher opened with one column")
392393 }
393- c, _ = switcherCore(t, 3)
394+ c, _, _ = switcherCore(t, 3)
394395 c.cur().mon.ToggleOverview()
395396 before := switcherFocus(c)
396- c.runBind(context.Background(), ActionSwitchColumnNext)
397+ bind(t, c, ActionSwitchColumnNext)
397398 if c.cur().mon.sw.open || c.switcher.sc != nil || switcherFocus(c) != before {
398399 t.Fatal("switcher acted in the overview")
399400 }
@@ -402,7 +403,7 @@ func TestSwitchIgnoredWithOneColumnOrOverview(t *testing.T) {
402403 // --- P2.3: release commit and timer ---
403404
404405 func TestSwitchQuickTapSwapsWithoutCards(t *testing.T) {
405- c, _ := switcherCore(t, 3)
406+ c, _, _ := switcherCore(t, 3)
406407 start := switcherFocus(c)
407408 for len(c.ch.Scenes) > 0 {
408409 <-c.ch.Scenes
@@ -435,26 +436,26 @@ func TestSwitchQuickTapSwapsWithoutCards(t *testing.T) {
435436 }
436437
437438 func TestSwitchHoldCommitsOnReleaseAndForwardsIt(t *testing.T) {
438- c, _ := switcherCore(t, 3)
439+ c, _, cmds := switcherCore(t, 3)
439440 start := switcherFocus(c)
440441 order := c.cur().mon.Current().switchOrder()
441442 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true), tab(ports.ModSuper, false))
442443 if switcherFocus(c) != start || !c.cur().mon.sw.open {
443444 t.Fatal("focus moved or switcher closed before the release")
444445 }
445- forwarded(c)
446+ forwarded(cmds)
446447 sendKeys(t, c, superKey(0, false))
447448 if switcherFocus(c) != order[1] || c.cur().mon.sw.open || c.switcher.timerC != nil {
448449 t.Fatalf("focus %d (want %d), open %v", switcherFocus(c), order[1], c.cur().mon.sw.open)
449450 }
450- fk := forwarded(c)
451+ fk := forwarded(cmds)
451452 if len(fk) != 1 || fk[0].Key.Keysym != "Super_L" || fk[0].Key.Pressed || fk[0].ID != order[1] {
452453 t.Fatalf("release not forwarded to the new focus: %+v", fk)
453454 }
454455 }
455456
456457 func TestSwitcherTickShowsCards(t *testing.T) {
457- c, _ := switcherCore(t, 3)
458+ c, _, _ := switcherCore(t, 3)
458459 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
459460 if c.cur().mon.sw.shown {
460461 t.Fatal("shown before the delay")
@@ -468,9 +469,9 @@ func TestSwitcherTickShowsCards(t *testing.T) {
468469 }
469470
470471 func TestSwitcherTickAfterCloseIsHarmless(t *testing.T) {
471- c, _ := switcherCore(t, 3)
472+ c, _, _ := switcherCore(t, 3)
472473 c.mods = ports.ModSuper
473- c.runBind(context.Background(), ActionSwitchColumnNext)
474+ bind(t, c, ActionSwitchColumnNext)
474475 c.cancelSwitcher()
475476 if c.switcherTick() {
476477 t.Fatal("tick on a closed switcher asks for a scene")
@@ -480,10 +481,10 @@ func TestSwitcherTickAfterCloseIsHarmless(t *testing.T) {
480481 // --- P3.1: keys and gestures ---
481482
482483 func TestSwitchEscapeCancelsAndIsNotForwarded(t *testing.T) {
483- c, _ := switcherCore(t, 3)
484+ c, _, cmds := switcherCore(t, 3)
484485 start := switcherFocus(c)
485486 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true), tab(ports.ModSuper, false))
486- forwarded(c)
487+ forwarded(cmds)
487488 esc := ports.KeyEvent{Keysym: "Escape", Keycode: 1, Mods: ports.ModSuper, Pressed: true}
488489 sendKeys(t, c, esc)
489490 esc.Pressed = false
@@ -491,7 +492,7 @@ func TestSwitchEscapeCancelsAndIsNotForwarded(t *testing.T) {
491492 if c.cur().mon.sw.open || c.switcher.sc != nil || switcherFocus(c) != start {
492493 t.Fatalf("open %v focus %d (want %d)", c.cur().mon.sw.open, switcherFocus(c), start)
493494 }
494- for _, k := range forwarded(c) {
495+ for _, k := range forwarded(cmds) {
495496 if k.Key.Keysym == "Escape" {
496497 t.Fatalf("Escape forwarded: %+v", k)
497498 }
@@ -499,7 +500,7 @@ func TestSwitchEscapeCancelsAndIsNotForwarded(t *testing.T) {
499500 }
500501
501502 func TestSwitchAnotherBindCancelsThenRuns(t *testing.T) {
502- c, _ := switcherCore(t, 3)
503+ c, _, _ := switcherCore(t, 3)
503504 start := switcherFocus(c)
504505 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
505506 sendKeys(t, c, ports.KeyEvent{Keysym: "f", Keycode: 33, Mods: ports.ModSuper, Pressed: true})
@@ -515,7 +516,7 @@ func TestSwitchAnotherBindCancelsThenRuns(t *testing.T) {
515516 }
516517
517518 func TestSwitchShiftTabGoesBackward(t *testing.T) {
518- c, _ := switcherCore(t, 3)
519+ c, _, _ := switcherCore(t, 3)
519520 sendKeys(t, c, superKey(ports.ModSuper, true), shiftTab(true))
520521 sw := c.cur().mon.sw
521522 if !sw.open || sw.at != len(sw.order)-1 {
@@ -528,7 +529,7 @@ func TestSwitchShiftTabGoesBackward(t *testing.T) {
528529 }
529530
530531 func TestSwitchSwipeBeginCancels(t *testing.T) {
531- c, _ := switcherCore(t, 3)
532+ c, _, _ := switcherCore(t, 3)
532533 start := switcherFocus(c)
533534 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
534535 c.swipeBegin(ports.SwipeBegin{Fingers: 3})
@@ -538,7 +539,7 @@ func TestSwitchSwipeBeginCancels(t *testing.T) {
538539 }
539540
540541 func TestSwitchSecurityChangeCancels(t *testing.T) {
541- c, _ := switcherCore(t, 3)
542+ c, _, _ := switcherCore(t, 3)
542543 state := ports.SecurityState{Generation: 1}
543544 gate := portsmocks.NewMockSessionSecurity(t)
544545 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }).Maybe()
@@ -555,7 +556,7 @@ func TestSwitchSecurityChangeCancels(t *testing.T) {
555556 }
556557
557558 func TestSwitchCmdKeyFollowsConfig(t *testing.T) {
558- c, _ := switcherCore(t, 3)
559+ c, _, _ := switcherCore(t, 3)
559560 cfg := c.cfg
560561 cfg.Keyboard.CmdKey = "alt"
561562 if err := c.apply(cfg); err != nil {
@@ -575,7 +576,7 @@ func TestSwitchCmdKeyFollowsConfig(t *testing.T) {
575576 // --- P3.2: layout ---
576577
577578 func TestSwitcherLayoutCards(t *testing.T) {
578- c, _ := switcherCore(t, 3)
579+ c, _, _ := switcherCore(t, 3)
579580 c.cfg.Floating.Dim = 0.4
580581 area := c.cur().mon.Current().overviewArea()
581582 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
@@ -644,7 +645,7 @@ func focusedIDReal(c *Core) WindowID {
644645 }
645646
646647 func TestSwitcherLayoutHidesOtherWindowsOfColumns(t *testing.T) {
647- c, _ := switcherCore(t, 3)
648+ c, _, _ := switcherCore(t, 3)
648649 c.cur().mon.AddWindow(4)
649650 w := c.cur().mon.Current()
650651 w.ConsumeOrExpel(-1)
@@ -670,18 +671,18 @@ func TestSwitcherLayoutHidesOtherWindowsOfColumns(t *testing.T) {
670671 }
671672
672673 func TestSwitcherCardsKeepKeysOnRealFocus(t *testing.T) {
673- c, _ := switcherCore(t, 3)
674+ c, _, cmds := switcherCore(t, 3)
674675 focus := switcherFocus(c)
675676 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
676677 c.switcherTick()
677678 indicatorScene(t, c)
678- forwarded(c)
679+ forwarded(cmds)
679680 if c.keyboardFocus() != focus {
680681 t.Fatalf("keyboard focus %d, want %d", c.keyboardFocus(), focus)
681682 }
682683 sendKeys(t, c, ports.KeyEvent{Keysym: "x", Keycode: 45, Mods: ports.ModSuper, Pressed: true})
683684 // An unbound key goes to the real focus (Cmd+x has no bind here).
684- fk := forwarded(c)
685+ fk := forwarded(cmds)
685686 if len(fk) != 1 || fk[0].ID != focus || fk[0].Key.Keysym != "x" {
686687 t.Fatalf("forwarded %+v, want x to %d", fk, focus)
687688 }
@@ -701,13 +702,13 @@ func cardCenter(t *testing.T, c *Core, id WindowID) (float64, float64) {
701702 }
702703
703704 func TestSwitcherClickOnCardCommits(t *testing.T) {
704- c, _ := switcherCore(t, 3)
705+ c, _, cmds := switcherCore(t, 3)
705706 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
706707 c.switcherTick()
707708 indicatorScene(t, c)
708709 target := c.cur().mon.sw.order[2]
709710 c.cursorX, c.cursorY = cardCenter(t, c, target)
710- forwarded(c)
711+ forwarded(cmds)
711712 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {
712713 t.Fatal(err)
713714 }
@@ -717,16 +718,15 @@ func TestSwitcherClickOnCardCommits(t *testing.T) {
717718 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110}); err != nil {
718719 t.Fatal(err)
719720 }
720- ch := switcherCommands[c]
721- for len(ch) > 0 {
722- if v, ok := (<-ch).(ports.PointerButtonTo); ok {
721+ for len(cmds) > 0 {
722+ if v, ok := (<-cmds).(ports.PointerButtonTo); ok {
723723 t.Fatalf("click reached a client: %+v", v)
724724 }
725725 }
726726 }
727727
728728 func TestSwitcherClickElsewhereCancels(t *testing.T) {
729- c, _ := switcherCore(t, 3)
729+ c, _, _ := switcherCore(t, 3)
730730 start := switcherFocus(c)
731731 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
732732 c.switcherTick()
@@ -741,7 +741,7 @@ func TestSwitcherClickElsewhereCancels(t *testing.T) {
741741 }
742742
743743 func TestSwitcherPressBeforeCardsCancelsAndContinues(t *testing.T) {
744- c, _ := switcherCore(t, 3)
744+ c, _, _ := switcherCore(t, 3)
745745 start := switcherFocus(c)
746746 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
747747 handled, err := c.switcherClick(context.Background(), 0x110)
@@ -756,7 +756,7 @@ func TestSwitcherPressBeforeCardsCancelsAndContinues(t *testing.T) {
756756 // --- P3.4: robustness ---
757757
758758 func TestSwitcherClosingSelectedWindowKeepsSelectionValid(t *testing.T) {
759- c, _ := switcherCore(t, 3)
759+ c, _, _ := switcherCore(t, 3)
760760 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
761761 c.switcherTick()
762762 sw := &c.cur().mon.sw
@@ -777,9 +777,9 @@ func TestSwitcherClosingSelectedWindowKeepsSelectionValid(t *testing.T) {
777777 }
778778
779779 func TestSwitcherClosingBeforeSelectionKeepsCard(t *testing.T) {
780- c, _ := switcherCore(t, 3)
780+ c, _, _ := switcherCore(t, 3)
781781 c.mods = ports.ModSuper
782- c.runBind(context.Background(), ActionSwitchColumnPrev)
782+ bind(t, c, ActionSwitchColumnPrev)
783783 sw := &c.cur().mon.sw
784784 selected := sw.order[sw.at]
785785 c.cur().mon.RemoveWindow(sw.order[0])
@@ -789,7 +789,7 @@ func TestSwitcherClosingBeforeSelectionKeepsCard(t *testing.T) {
789789 }
790790
791791 func TestSwitcherClosingDownToOneColumnClosesIt(t *testing.T) {
792- c, _ := switcherCore(t, 2)
792+ c, _, _ := switcherCore(t, 2)
793793 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
794794 c.switcherTick()
795795 c.cur().mon.RemoveWindow(c.cur().mon.sw.order[1])
@@ -807,7 +807,7 @@ func TestSwitcherClosingDownToOneColumnClosesIt(t *testing.T) {
807807 }
808808
809809 func TestSwitcherOutputRemovalCancels(t *testing.T) {
810- c, _ := switcherCore(t, 3)
810+ c, _, _ := switcherCore(t, 3)
811811 c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
812812 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
813813 c.switcherTick()
@@ -828,7 +828,7 @@ func TestSwitcherOutputRemovalCancels(t *testing.T) {
828828 }
829829
830830 func TestSwitcherFocusMovingToAnotherOutputReopensThere(t *testing.T) {
831- c, _ := switcherCore(t, 3)
831+ c, _, _ := switcherCore(t, 3)
832832 c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
833833 first := c.cur()
834834 for id := WindowID(11); id <= 13; id++ {
@@ -836,12 +836,12 @@ func TestSwitcherFocusMovingToAnotherOutputReopensThere(t *testing.T) {
836836 }
837837 indicatorScene(t, c)
838838 c.mods = ports.ModSuper
839- c.runBind(context.Background(), ActionSwitchColumnNext)
839+ bind(t, c, ActionSwitchColumnNext)
840840 if c.switcher.sc != first {
841841 t.Fatal("switcher not on the focused screen")
842842 }
843843 c.focusScreen = c.screenIndex("B")
844- c.runBind(context.Background(), ActionSwitchColumnNext)
844+ bind(t, c, ActionSwitchColumnNext)
845845 if first.mon.sw.open {
846846 t.Fatal("old switcher left open")
847847 }
@@ -853,7 +853,7 @@ func TestSwitcherFocusMovingToAnotherOutputReopensThere(t *testing.T) {
853853 // With animations on, the cards fly in and the commit flies them back; the
854854 // windows end where the layout puts them, with no card left.
855855 func TestSwitcherAnimatedHoldAndCommit(t *testing.T) {
856- c, ic := switcherCore(t, 3)
856+ c, ic, _ := switcherCore(t, 3)
857857 c.cfg.Animations.On = true
858858 target := c.cur().mon.Current().switchOrder()[1]
859859 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
@@ -876,3 +876,227 @@ func TestSwitcherAnimatedHoldAndCommit(t *testing.T) {
876876 t.Fatalf("cards %d, focus %d (want %d)", len(scenePreviews(s)), switcherFocus(c), target)
877877 }
878878 }
879+
880+// --- review fixes ---
881+
882+// twoWorkspaces is a switcher core whose second workspace holds two
883+// windows (10, 11) and is not on screen.
884+func twoWorkspaces(t *testing.T) (*Core, *Workspace) {
885+ t.Helper()
886+ c, _, _ := switcherCore(t, 3)
887+ c.cfg.Floating.Dim = 0.4
888+ m := c.cur().mon
889+ first := m.Current()
890+ m.Focus(1)
891+ m.AddWindow(10)
892+ m.AddWindow(11)
893+ other := m.Current()
894+ m.show(first)
895+ if other == first || m.Current() != first {
896+ t.Fatal("workspaces not set up")
897+ }
898+ indicatorScene(t, c)
899+ return c, other
900+}
901+
902+func TestSwitcherShownDropsWhenAnotherWorkspaceComesOnScreen(t *testing.T) {
903+ c, other := twoWorkspaces(t)
904+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
905+ c.switcherTick()
906+ if s := indicatorScene(t, c); len(scenePreviews(s)) != 3 {
907+ t.Fatalf("cards not shown: %+v", s.Windows)
908+ }
909+ // An xdg-activation request for a window of the other workspace.
910+ if err := c.activate(context.Background(), 10); err != nil {
911+ t.Fatal(err)
912+ }
913+ m := c.cur().mon
914+ if m.Current() != other || m.previewing() {
915+ t.Fatalf("on screen %v, previewing %v", m.Current() == other, m.previewing())
916+ }
917+ s := indicatorScene(t, c)
918+ if len(scenePreviews(s)) != 0 || s.Dim != 0 || s.DimBehind {
919+ t.Fatalf("scene still dimmed or previewing: dim %v previews %+v", s.Dim, scenePreviews(s))
920+ }
921+ if m.sw.open || c.switcher.sc != nil || c.switcher.timerC != nil {
922+ t.Fatal("stale switcher left open")
923+ }
924+ var any bool
925+ for _, p := range c.cur().shownLayout() {
926+ if p.ID == 10 || p.ID == 11 {
927+ x, y := float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2)
928+ if id, _, _ := c.hit(x, y); id != p.ID {
929+ t.Fatalf("hit %d at window %d", id, p.ID)
930+ }
931+ any = true
932+ }
933+ }
934+ if !any {
935+ t.Fatal("workspace windows not laid out")
936+ }
937+ // The next switch opens fresh on the new workspace.
938+ c.mods = ports.ModSuper
939+ bind(t, c, ActionSwitchColumnNext)
940+ if sw := m.sw; !sw.open || sw.ws != other || sw.at != 1 {
941+ t.Fatalf("open %v on other %v at %d", sw.open, sw.ws == other, sw.at)
942+ }
943+}
944+
945+func TestSwitcherStepOnStaleWorkspaceOpensFresh(t *testing.T) {
946+ c, other := twoWorkspaces(t)
947+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
948+ c.switcherTick()
949+ // Shown on screen, then the workspace changes with no publish between.
950+ c.cur().mon.show(other)
951+ if c.cur().mon.previewing() {
952+ t.Fatal("stale cards still count as previews")
953+ }
954+ sendKeys(t, c, tab(ports.ModSuper, true))
955+ sw := c.cur().mon.sw
956+ if !sw.open || sw.ws != other || sw.shown || sw.at != 1 {
957+ t.Fatalf("open %v on other %v shown %v at %d", sw.open, sw.ws == other, sw.shown, sw.at)
958+ }
959+ // A release on a stale switcher commits nothing and closes it.
960+ c.cur().mon.show(c.cur().mon.Workspaces[0])
961+ focus := switcherFocus(c)
962+ sendKeys(t, c, superKey(0, false))
963+ if c.cur().mon.sw.open || c.switcher.sc != nil || switcherFocus(c) != focus {
964+ t.Fatal("stale switcher survived the release")
965+ }
966+}
967+
968+func TestSwitchIgnoredDuringDrag(t *testing.T) {
969+ c, _, _ := switcherCore(t, 3)
970+ start := switcherFocus(c)
971+ d := &dragState{id: start, button: btnLeft}
972+ c.drag = d
973+ c.buttons[btnLeft] = true
974+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
975+ if c.cur().mon.sw.open || c.switcher.sc != nil || c.switcher.timerC != nil {
976+ t.Fatal("switcher opened during a drag")
977+ }
978+ if c.drag != d || switcherFocus(c) != start {
979+ t.Fatal("drag disturbed")
980+ }
981+ sendKeys(t, c, ports.KeyEvent{Keysym: "Escape", Keycode: 1, Mods: ports.ModSuper, Pressed: true})
982+ if c.drag != nil {
983+ t.Fatal("Escape did not cancel the drag")
984+ }
985+}
986+
987+// Another bind closing the shown cards still flies them back: the snapshot
988+// is taken while they are drawn, so the windows move from the card rects.
989+func TestSwitchBindOverShownCardsFliesBack(t *testing.T) {
990+ c, ic, _ := switcherCore(t, 3)
991+ c.cfg.Animations.On = true
992+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
993+ c.switcherTick()
994+ for range 200 {
995+ ic.now = ic.now.Add(16 * time.Millisecond)
996+ if err := c.step(context.Background(), nil); err != nil {
997+ t.Fatal(err)
998+ }
999+ }
1000+ sc := c.cur()
1001+ cards := map[WindowID]Rect{}
1002+ for _, p := range sc.shownLayout() {
1003+ if p.Preview > 0 {
1004+ cards[p.ID] = p.Rect
1005+ }
1006+ }
1007+ if len(cards) != 3 || len(sc.rects) != 0 {
1008+ t.Fatalf("%d cards, %d motions: the cards did not settle", len(cards), len(sc.rects))
1009+ }
1010+ bind(t, c, ActionFocusColumnLeft)
1011+ if c.switching() || sc.mon.sw.shown {
1012+ t.Fatal("the bind left the switcher open")
1013+ }
1014+ settled := map[WindowID]Rect{}
1015+ for _, p := range sc.mon.Layout() {
1016+ settled[p.ID] = p.Rect
1017+ }
1018+ for id, card := range cards {
1019+ rm, ok := sc.rects[id]
1020+ if !ok || !rm.scale {
1021+ t.Fatalf("window %d has no fly-back motion", id)
1022+ }
1023+ if got := rm.apply(settled[id]); got != card {
1024+ t.Fatalf("window %d flies back from %+v, want its card %+v", id, got, card)
1025+ }
1026+ }
1027+}
1028+
1029+func TestSwitcherClosingSelectedWindowPicksColumnsNextWindow(t *testing.T) {
1030+ m := NewMonitor()
1031+ m.SetOutput(300, 200)
1032+ m.SetMaxColumns(3)
1033+ for id := WindowID(1); id <= 4; id++ {
1034+ m.AddWindow(id)
1035+ }
1036+ w := m.Current()
1037+ w.FocusID(3)
1038+ w.ConsumeOrExpel(-1) // columns [1] [2 3] [4]
1039+ if len(w.Columns) != 3 || len(w.Columns[1].Windows) != 2 {
1040+ t.Fatalf("columns %+v", w.Columns)
1041+ }
1042+ w.FocusID(1)
1043+ openSwitcher(m, 0)
1044+ at := slices.IndexFunc(m.sw.order, func(id WindowID) bool { return w.columnOf(id) == 1 })
1045+ m.sw.at = at
1046+ gone := m.sw.order[at]
1047+ other := w.Columns[1].Windows[0]
1048+ if other == gone {
1049+ other = w.Columns[1].Windows[1]
1050+ }
1051+ m.RemoveWindow(gone)
1052+ if len(m.sw.order) != 3 || m.sw.at != at || m.sw.order[at] != other {
1053+ t.Fatalf("order %v at %d, want card %d to stand for %d", m.sw.order, m.sw.at, at, other)
1054+ }
1055+ m.commitSwitcher()
1056+ if id, _ := w.Focused(); id != other {
1057+ t.Fatalf("focus %d, want %d", id, other)
1058+ }
1059+}
1060+
1061+func TestSwitchCommitFocusesTileUnderFloat(t *testing.T) {
1062+ c, _, _ := switcherCore(t, 3)
1063+ m := c.cur().mon
1064+ m.AddFloating(9, 100, 80)
1065+ w := m.Current()
1066+ if !w.floatFocus {
1067+ t.Fatal("float not focused")
1068+ }
1069+ tile := w.Columns[w.Focus].Windows[0]
1070+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1071+ bind(t, c, ActionSwitchColumnPrev) // back to card 0: the column under the float
1072+ if m.sw.at != 0 || m.sw.order[0] != tile {
1073+ t.Fatalf("at %d order %v", m.sw.at, m.sw.order)
1074+ }
1075+ sendKeys(t, c, superKey(0, false))
1076+ if w.floatFocus || switcherFocus(c) != tile {
1077+ t.Fatalf("float focused %v, focus %d, want tile %d", w.floatFocus, switcherFocus(c), tile)
1078+ }
1079+}
1080+
1081+func TestSwitchCommitFollowsToItsOutput(t *testing.T) {
1082+ c, _, _ := switcherCore(t, 3)
1083+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
1084+ first := c.focusScreen
1085+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1086+ want := c.cur().mon.sw.order[1]
1087+ // The pointer crosses to the other output meanwhile.
1088+ c.focusScreen = c.screenIndex("B")
1089+ sendKeys(t, c, superKey(0, false))
1090+ if c.focusScreen != first || switcherFocus(c) != want {
1091+ t.Fatalf("focus screen %d (want %d), focus %d (want %d)", c.focusScreen, first, switcherFocus(c), want)
1092+ }
1093+}
1094+
1095+func TestSwitchUnmodifiedBindThroughKeyEvent(t *testing.T) {
1096+ c, ic, _ := switcherCore(t, 3)
1097+ start := switcherFocus(c)
1098+ sendKeys(t, c, ports.KeyEvent{Keysym: "F9", Keycode: 75, Pressed: true})
1099+ if c.cur().mon.sw.open || len(ic.timers) != 0 || switcherFocus(c) == start {
1100+ t.Fatalf("open %v timers %v focus %d (was %d)", c.cur().mon.sw.open, ic.timers, switcherFocus(c), start)
1101+ }
1102+}
@@ -149,12 +149,10 @@ const (
149 // switcherCore is indicatorCore with the switcher binds, up to three149 // switcherCore is indicatorCore with the switcher binds, up to three
150 // columns on screen and n one-window columns (windows 1..n, n focused),150 // columns on screen and n one-window columns (windows 1..n, n focused),
151 // published once so the configures carry the windows' sizes.151 // published once so the configures carry the windows' sizes.
152-func switcherCore(t *testing.T, n int) (*Core, *indicatorClock) {152+func switcherCore(t *testing.T, n int) (*Core, *indicatorClock, <-chan ports.ClientCommand) {
153 t.Helper()153 t.Helper()
154 commands := make(chan ports.ClientCommand, 64)154 commands := make(chan ports.ClientCommand, 64)
155 c, ic := indicatorCoreWith(t, commands)155 c, ic := indicatorCoreWith(t, commands)
156- switcherCommands[c] = commands
157- t.Cleanup(func() { delete(switcherCommands, c) })
158 cfg := c.cfg156 cfg := c.cfg
159 cfg.Layout.MaxColumns = 3157 cfg.Layout.MaxColumns = 3
160 cfg.Binds = switcherBinds158 cfg.Binds = switcherBinds
@@ -165,7 +163,7 @@ func switcherCore(t *testing.T, n int) (*Core, *indicatorClock) {
165 c.cur().mon.AddWindow(id)163 c.cur().mon.AddWindow(id)
166 }164 }
167 indicatorScene(t, c)165 indicatorScene(t, c)
168- return c, ic166+ return c, ic, commands
169 }167 }
170 168
171 func tab(mods ports.Mods, pressed bool) ports.KeyEvent {169 func tab(mods ports.Mods, pressed bool) ports.KeyEvent {
@@ -180,6 +178,13 @@ func superKey(mods ports.Mods, pressed bool) ports.KeyEvent {
180 return ports.KeyEvent{Keysym: "Super_L", Keycode: keySuper, Mods: mods, Pressed: pressed}178 return ports.KeyEvent{Keysym: "Super_L", Keycode: keySuper, Mods: mods, Pressed: pressed}
181 }179 }
182 180
181+func bind(t *testing.T, c *Core, a Action) {
182+ t.Helper()
183+ if err := c.runBind(context.Background(), a); err != nil {
184+ t.Fatal(err)
185+ }
186+}
187+
183 func sendKeys(t *testing.T, c *Core, keys ...ports.KeyEvent) {188 func sendKeys(t *testing.T, c *Core, keys ...ports.KeyEvent) {
184 t.Helper()189 t.Helper()
185 for _, k := range keys {190 for _, k := range keys {
@@ -207,13 +212,9 @@ func switcherFocus(c *Core) WindowID {
207 return id212 return id
208 }213 }
209 214
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.215 // forwarded drains the commands and returns the forwarded keys.
214-func forwarded(c *Core) []ports.ForwardKey {216+func forwarded(ch <-chan ports.ClientCommand) []ports.ForwardKey {
215 var out []ports.ForwardKey217 var out []ports.ForwardKey
216- ch := switcherCommands[c]
217 for len(ch) > 0 {218 for len(ch) > 0 {
218 if v, ok := (<-ch).(ports.ForwardKey); ok {219 if v, ok := (<-ch).(ports.ForwardKey); ok {
219 out = append(out, v)220 out = append(out, v)
@@ -321,7 +322,7 @@ func TestSwitchCommitIgnoresGoneWindowAndOtherWorkspace(t *testing.T) {
321 // --- P2.2: actions and step ---322 // --- P2.2: actions and step ---
322 323
323 func TestSwitchStepOpensOnPreviousColumnAndCycles(t *testing.T) {324 func TestSwitchStepOpensOnPreviousColumnAndCycles(t *testing.T) {
324- c, ic := switcherCore(t, 3)325+ c, ic, _ := switcherCore(t, 3)
325 c.mods = ports.ModSuper326 c.mods = ports.ModSuper
326 before := switcherFocus(c)327 before := switcherFocus(c)
327 if err := c.runBind(context.Background(), ActionSwitchColumnNext); err != nil {328 if err := c.runBind(context.Background(), ActionSwitchColumnNext); err != nil {
@@ -334,19 +335,19 @@ func TestSwitchStepOpensOnPreviousColumnAndCycles(t *testing.T) {
334 if len(ic.timers) != 1 || ic.timers[0] != switcherDelay {335 if len(ic.timers) != 1 || ic.timers[0] != switcherDelay {
335 t.Fatalf("timers %v, want one of %v", ic.timers, switcherDelay)336 t.Fatalf("timers %v, want one of %v", ic.timers, switcherDelay)
336 }337 }
337- c.runBind(context.Background(), ActionSwitchColumnNext)338+ bind(t, c, ActionSwitchColumnNext)
338 if sw.at != 2 {339 if sw.at != 2 {
339 t.Fatalf("at %d", sw.at)340 t.Fatalf("at %d", sw.at)
340 }341 }
341- c.runBind(context.Background(), ActionSwitchColumnNext)342+ bind(t, c, ActionSwitchColumnNext)
342 if sw.at != 0 {343 if sw.at != 0 {
343 t.Fatalf("at %d, want wrap to 0", sw.at)344 t.Fatalf("at %d, want wrap to 0", sw.at)
344 }345 }
345- c.runBind(context.Background(), ActionSwitchColumnPrev)346+ bind(t, c, ActionSwitchColumnPrev)
346 if sw.at != 2 {347 if sw.at != 2 {
347 t.Fatalf("at %d, want wrap to 2", sw.at)348 t.Fatalf("at %d, want wrap to 2", sw.at)
348 }349 }
349- c.runBind(context.Background(), ActionSwitchColumnPrev)350+ bind(t, c, ActionSwitchColumnPrev)
350 if sw.at != 1 {351 if sw.at != 1 {
351 t.Fatalf("at %d", sw.at)352 t.Fatalf("at %d", sw.at)
352 }353 }
@@ -356,16 +357,16 @@ func TestSwitchStepOpensOnPreviousColumnAndCycles(t *testing.T) {
356 }357 }
357 358
358 func TestSwitchPrevOpensOnOldestColumn(t *testing.T) {359 func TestSwitchPrevOpensOnOldestColumn(t *testing.T) {
359- c, _ := switcherCore(t, 3)360+ c, _, _ := switcherCore(t, 3)
360 c.mods = ports.ModSuper361 c.mods = ports.ModSuper
361- c.runBind(context.Background(), ActionSwitchColumnPrev)362+ bind(t, c, ActionSwitchColumnPrev)
362 if sw := c.cur().mon.sw; !sw.open || sw.at != len(sw.order)-1 {363 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 t.Fatalf("at %d of %d", sw.at, len(sw.order))
364 }365 }
365 }366 }
366 367
367 func TestSwitchWithoutCmdCommitsAtOnce(t *testing.T) {368 func TestSwitchWithoutCmdCommitsAtOnce(t *testing.T) {
368- c, ic := switcherCore(t, 3)369+ c, ic, _ := switcherCore(t, 3)
369 c.mods = 0370 c.mods = 0
370 start := switcherFocus(c)371 start := switcherFocus(c)
371 if err := c.runBind(context.Background(), ActionSwitchColumnNext); err != nil {372 if err := c.runBind(context.Background(), ActionSwitchColumnNext); err != nil {
@@ -377,23 +378,23 @@ func TestSwitchWithoutCmdCommitsAtOnce(t *testing.T) {
377 if switcherFocus(c) == start {378 if switcherFocus(c) == start {
378 t.Fatal("focus did not move")379 t.Fatal("focus did not move")
379 }380 }
380- c.runBind(context.Background(), ActionSwitchColumnNext)381+ bind(t, c, ActionSwitchColumnNext)
381 if switcherFocus(c) != start {382 if switcherFocus(c) != start {
382 t.Fatalf("focus %d, want back on %d", switcherFocus(c), start)383 t.Fatalf("focus %d, want back on %d", switcherFocus(c), start)
383 }384 }
384 }385 }
385 386
386 func TestSwitchIgnoredWithOneColumnOrOverview(t *testing.T) {387 func TestSwitchIgnoredWithOneColumnOrOverview(t *testing.T) {
387- c, _ := switcherCore(t, 1)388+ c, _, _ := switcherCore(t, 1)
388 c.mods = ports.ModSuper389 c.mods = ports.ModSuper
389- c.runBind(context.Background(), ActionSwitchColumnNext)390+ bind(t, c, ActionSwitchColumnNext)
390 if c.cur().mon.sw.open || c.switcher.sc != nil {391 if c.cur().mon.sw.open || c.switcher.sc != nil {
391 t.Fatal("switcher opened with one column")392 t.Fatal("switcher opened with one column")
392 }393 }
393- c, _ = switcherCore(t, 3)394+ c, _, _ = switcherCore(t, 3)
394 c.cur().mon.ToggleOverview()395 c.cur().mon.ToggleOverview()
395 before := switcherFocus(c)396 before := switcherFocus(c)
396- c.runBind(context.Background(), ActionSwitchColumnNext)397+ bind(t, c, ActionSwitchColumnNext)
397 if c.cur().mon.sw.open || c.switcher.sc != nil || switcherFocus(c) != before {398 if c.cur().mon.sw.open || c.switcher.sc != nil || switcherFocus(c) != before {
398 t.Fatal("switcher acted in the overview")399 t.Fatal("switcher acted in the overview")
399 }400 }
@@ -402,7 +403,7 @@ func TestSwitchIgnoredWithOneColumnOrOverview(t *testing.T) {
402 // --- P2.3: release commit and timer ---403 // --- P2.3: release commit and timer ---
403 404
404 func TestSwitchQuickTapSwapsWithoutCards(t *testing.T) {405 func TestSwitchQuickTapSwapsWithoutCards(t *testing.T) {
405- c, _ := switcherCore(t, 3)406+ c, _, _ := switcherCore(t, 3)
406 start := switcherFocus(c)407 start := switcherFocus(c)
407 for len(c.ch.Scenes) > 0 {408 for len(c.ch.Scenes) > 0 {
408 <-c.ch.Scenes409 <-c.ch.Scenes
@@ -435,26 +436,26 @@ func TestSwitchQuickTapSwapsWithoutCards(t *testing.T) {
435 }436 }
436 437
437 func TestSwitchHoldCommitsOnReleaseAndForwardsIt(t *testing.T) {438 func TestSwitchHoldCommitsOnReleaseAndForwardsIt(t *testing.T) {
438- c, _ := switcherCore(t, 3)439+ c, _, cmds := switcherCore(t, 3)
439 start := switcherFocus(c)440 start := switcherFocus(c)
440 order := c.cur().mon.Current().switchOrder()441 order := c.cur().mon.Current().switchOrder()
441 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true), tab(ports.ModSuper, false))442 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 if switcherFocus(c) != start || !c.cur().mon.sw.open {
443 t.Fatal("focus moved or switcher closed before the release")444 t.Fatal("focus moved or switcher closed before the release")
444 }445 }
445- forwarded(c)446+ forwarded(cmds)
446 sendKeys(t, c, superKey(0, false))447 sendKeys(t, c, superKey(0, false))
447 if switcherFocus(c) != order[1] || c.cur().mon.sw.open || c.switcher.timerC != nil {448 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 t.Fatalf("focus %d (want %d), open %v", switcherFocus(c), order[1], c.cur().mon.sw.open)
449 }450 }
450- fk := forwarded(c)451+ fk := forwarded(cmds)
451 if len(fk) != 1 || fk[0].Key.Keysym != "Super_L" || fk[0].Key.Pressed || fk[0].ID != order[1] {452 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 t.Fatalf("release not forwarded to the new focus: %+v", fk)
453 }454 }
454 }455 }
455 456
456 func TestSwitcherTickShowsCards(t *testing.T) {457 func TestSwitcherTickShowsCards(t *testing.T) {
457- c, _ := switcherCore(t, 3)458+ c, _, _ := switcherCore(t, 3)
458 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))459 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
459 if c.cur().mon.sw.shown {460 if c.cur().mon.sw.shown {
460 t.Fatal("shown before the delay")461 t.Fatal("shown before the delay")
@@ -468,9 +469,9 @@ func TestSwitcherTickShowsCards(t *testing.T) {
468 }469 }
469 470
470 func TestSwitcherTickAfterCloseIsHarmless(t *testing.T) {471 func TestSwitcherTickAfterCloseIsHarmless(t *testing.T) {
471- c, _ := switcherCore(t, 3)472+ c, _, _ := switcherCore(t, 3)
472 c.mods = ports.ModSuper473 c.mods = ports.ModSuper
473- c.runBind(context.Background(), ActionSwitchColumnNext)474+ bind(t, c, ActionSwitchColumnNext)
474 c.cancelSwitcher()475 c.cancelSwitcher()
475 if c.switcherTick() {476 if c.switcherTick() {
476 t.Fatal("tick on a closed switcher asks for a scene")477 t.Fatal("tick on a closed switcher asks for a scene")
@@ -480,10 +481,10 @@ func TestSwitcherTickAfterCloseIsHarmless(t *testing.T) {
480 // --- P3.1: keys and gestures ---481 // --- P3.1: keys and gestures ---
481 482
482 func TestSwitchEscapeCancelsAndIsNotForwarded(t *testing.T) {483 func TestSwitchEscapeCancelsAndIsNotForwarded(t *testing.T) {
483- c, _ := switcherCore(t, 3)484+ c, _, cmds := switcherCore(t, 3)
484 start := switcherFocus(c)485 start := switcherFocus(c)
485 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true), tab(ports.ModSuper, false))486 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true), tab(ports.ModSuper, false))
486- forwarded(c)487+ forwarded(cmds)
487 esc := ports.KeyEvent{Keysym: "Escape", Keycode: 1, Mods: ports.ModSuper, Pressed: true}488 esc := ports.KeyEvent{Keysym: "Escape", Keycode: 1, Mods: ports.ModSuper, Pressed: true}
488 sendKeys(t, c, esc)489 sendKeys(t, c, esc)
489 esc.Pressed = false490 esc.Pressed = false
@@ -491,7 +492,7 @@ func TestSwitchEscapeCancelsAndIsNotForwarded(t *testing.T) {
491 if c.cur().mon.sw.open || c.switcher.sc != nil || switcherFocus(c) != start {492 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 t.Fatalf("open %v focus %d (want %d)", c.cur().mon.sw.open, switcherFocus(c), start)
493 }494 }
494- for _, k := range forwarded(c) {495+ for _, k := range forwarded(cmds) {
495 if k.Key.Keysym == "Escape" {496 if k.Key.Keysym == "Escape" {
496 t.Fatalf("Escape forwarded: %+v", k)497 t.Fatalf("Escape forwarded: %+v", k)
497 }498 }
@@ -499,7 +500,7 @@ func TestSwitchEscapeCancelsAndIsNotForwarded(t *testing.T) {
499 }500 }
500 501
501 func TestSwitchAnotherBindCancelsThenRuns(t *testing.T) {502 func TestSwitchAnotherBindCancelsThenRuns(t *testing.T) {
502- c, _ := switcherCore(t, 3)503+ c, _, _ := switcherCore(t, 3)
503 start := switcherFocus(c)504 start := switcherFocus(c)
504 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))505 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 sendKeys(t, c, ports.KeyEvent{Keysym: "f", Keycode: 33, Mods: ports.ModSuper, Pressed: true})
@@ -515,7 +516,7 @@ func TestSwitchAnotherBindCancelsThenRuns(t *testing.T) {
515 }516 }
516 517
517 func TestSwitchShiftTabGoesBackward(t *testing.T) {518 func TestSwitchShiftTabGoesBackward(t *testing.T) {
518- c, _ := switcherCore(t, 3)519+ c, _, _ := switcherCore(t, 3)
519 sendKeys(t, c, superKey(ports.ModSuper, true), shiftTab(true))520 sendKeys(t, c, superKey(ports.ModSuper, true), shiftTab(true))
520 sw := c.cur().mon.sw521 sw := c.cur().mon.sw
521 if !sw.open || sw.at != len(sw.order)-1 {522 if !sw.open || sw.at != len(sw.order)-1 {
@@ -528,7 +529,7 @@ func TestSwitchShiftTabGoesBackward(t *testing.T) {
528 }529 }
529 530
530 func TestSwitchSwipeBeginCancels(t *testing.T) {531 func TestSwitchSwipeBeginCancels(t *testing.T) {
531- c, _ := switcherCore(t, 3)532+ c, _, _ := switcherCore(t, 3)
532 start := switcherFocus(c)533 start := switcherFocus(c)
533 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))534 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
534 c.swipeBegin(ports.SwipeBegin{Fingers: 3})535 c.swipeBegin(ports.SwipeBegin{Fingers: 3})
@@ -538,7 +539,7 @@ func TestSwitchSwipeBeginCancels(t *testing.T) {
538 }539 }
539 540
540 func TestSwitchSecurityChangeCancels(t *testing.T) {541 func TestSwitchSecurityChangeCancels(t *testing.T) {
541- c, _ := switcherCore(t, 3)542+ c, _, _ := switcherCore(t, 3)
542 state := ports.SecurityState{Generation: 1}543 state := ports.SecurityState{Generation: 1}
543 gate := portsmocks.NewMockSessionSecurity(t)544 gate := portsmocks.NewMockSessionSecurity(t)
544 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }).Maybe()545 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }).Maybe()
@@ -555,7 +556,7 @@ func TestSwitchSecurityChangeCancels(t *testing.T) {
555 }556 }
556 557
557 func TestSwitchCmdKeyFollowsConfig(t *testing.T) {558 func TestSwitchCmdKeyFollowsConfig(t *testing.T) {
558- c, _ := switcherCore(t, 3)559+ c, _, _ := switcherCore(t, 3)
559 cfg := c.cfg560 cfg := c.cfg
560 cfg.Keyboard.CmdKey = "alt"561 cfg.Keyboard.CmdKey = "alt"
561 if err := c.apply(cfg); err != nil {562 if err := c.apply(cfg); err != nil {
@@ -575,7 +576,7 @@ func TestSwitchCmdKeyFollowsConfig(t *testing.T) {
575 // --- P3.2: layout ---576 // --- P3.2: layout ---
576 577
577 func TestSwitcherLayoutCards(t *testing.T) {578 func TestSwitcherLayoutCards(t *testing.T) {
578- c, _ := switcherCore(t, 3)579+ c, _, _ := switcherCore(t, 3)
579 c.cfg.Floating.Dim = 0.4580 c.cfg.Floating.Dim = 0.4
580 area := c.cur().mon.Current().overviewArea()581 area := c.cur().mon.Current().overviewArea()
581 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))582 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
@@ -644,7 +645,7 @@ func focusedIDReal(c *Core) WindowID {
644 }645 }
645 646
646 func TestSwitcherLayoutHidesOtherWindowsOfColumns(t *testing.T) {647 func TestSwitcherLayoutHidesOtherWindowsOfColumns(t *testing.T) {
647- c, _ := switcherCore(t, 3)648+ c, _, _ := switcherCore(t, 3)
648 c.cur().mon.AddWindow(4)649 c.cur().mon.AddWindow(4)
649 w := c.cur().mon.Current()650 w := c.cur().mon.Current()
650 w.ConsumeOrExpel(-1)651 w.ConsumeOrExpel(-1)
@@ -670,18 +671,18 @@ func TestSwitcherLayoutHidesOtherWindowsOfColumns(t *testing.T) {
670 }671 }
671 672
672 func TestSwitcherCardsKeepKeysOnRealFocus(t *testing.T) {673 func TestSwitcherCardsKeepKeysOnRealFocus(t *testing.T) {
673- c, _ := switcherCore(t, 3)674+ c, _, cmds := switcherCore(t, 3)
674 focus := switcherFocus(c)675 focus := switcherFocus(c)
675 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))676 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
676 c.switcherTick()677 c.switcherTick()
677 indicatorScene(t, c)678 indicatorScene(t, c)
678- forwarded(c)679+ forwarded(cmds)
679 if c.keyboardFocus() != focus {680 if c.keyboardFocus() != focus {
680 t.Fatalf("keyboard focus %d, want %d", c.keyboardFocus(), focus)681 t.Fatalf("keyboard focus %d, want %d", c.keyboardFocus(), focus)
681 }682 }
682 sendKeys(t, c, ports.KeyEvent{Keysym: "x", Keycode: 45, Mods: ports.ModSuper, Pressed: true})683 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 // An unbound key goes to the real focus (Cmd+x has no bind here).
684- fk := forwarded(c)685+ fk := forwarded(cmds)
685 if len(fk) != 1 || fk[0].ID != focus || fk[0].Key.Keysym != "x" {686 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 t.Fatalf("forwarded %+v, want x to %d", fk, focus)
687 }688 }
@@ -701,13 +702,13 @@ func cardCenter(t *testing.T, c *Core, id WindowID) (float64, float64) {
701 }702 }
702 703
703 func TestSwitcherClickOnCardCommits(t *testing.T) {704 func TestSwitcherClickOnCardCommits(t *testing.T) {
704- c, _ := switcherCore(t, 3)705+ c, _, cmds := switcherCore(t, 3)
705 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))706 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
706 c.switcherTick()707 c.switcherTick()
707 indicatorScene(t, c)708 indicatorScene(t, c)
708 target := c.cur().mon.sw.order[2]709 target := c.cur().mon.sw.order[2]
709 c.cursorX, c.cursorY = cardCenter(t, c, target)710 c.cursorX, c.cursorY = cardCenter(t, c, target)
710- forwarded(c)711+ forwarded(cmds)
711 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {712 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {
712 t.Fatal(err)713 t.Fatal(err)
713 }714 }
@@ -717,16 +718,15 @@ func TestSwitcherClickOnCardCommits(t *testing.T) {
717 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110}); err != nil {718 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110}); err != nil {
718 t.Fatal(err)719 t.Fatal(err)
719 }720 }
720- ch := switcherCommands[c]721+ for len(cmds) > 0 {
721- for len(ch) > 0 {722+ if v, ok := (<-cmds).(ports.PointerButtonTo); ok {
722- if v, ok := (<-ch).(ports.PointerButtonTo); ok {
723 t.Fatalf("click reached a client: %+v", v)723 t.Fatalf("click reached a client: %+v", v)
724 }724 }
725 }725 }
726 }726 }
727 727
728 func TestSwitcherClickElsewhereCancels(t *testing.T) {728 func TestSwitcherClickElsewhereCancels(t *testing.T) {
729- c, _ := switcherCore(t, 3)729+ c, _, _ := switcherCore(t, 3)
730 start := switcherFocus(c)730 start := switcherFocus(c)
731 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))731 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
732 c.switcherTick()732 c.switcherTick()
@@ -741,7 +741,7 @@ func TestSwitcherClickElsewhereCancels(t *testing.T) {
741 }741 }
742 742
743 func TestSwitcherPressBeforeCardsCancelsAndContinues(t *testing.T) {743 func TestSwitcherPressBeforeCardsCancelsAndContinues(t *testing.T) {
744- c, _ := switcherCore(t, 3)744+ c, _, _ := switcherCore(t, 3)
745 start := switcherFocus(c)745 start := switcherFocus(c)
746 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))746 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
747 handled, err := c.switcherClick(context.Background(), 0x110)747 handled, err := c.switcherClick(context.Background(), 0x110)
@@ -756,7 +756,7 @@ func TestSwitcherPressBeforeCardsCancelsAndContinues(t *testing.T) {
756 // --- P3.4: robustness ---756 // --- P3.4: robustness ---
757 757
758 func TestSwitcherClosingSelectedWindowKeepsSelectionValid(t *testing.T) {758 func TestSwitcherClosingSelectedWindowKeepsSelectionValid(t *testing.T) {
759- c, _ := switcherCore(t, 3)759+ c, _, _ := switcherCore(t, 3)
760 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))760 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
761 c.switcherTick()761 c.switcherTick()
762 sw := &c.cur().mon.sw762 sw := &c.cur().mon.sw
@@ -777,9 +777,9 @@ func TestSwitcherClosingSelectedWindowKeepsSelectionValid(t *testing.T) {
777 }777 }
778 778
779 func TestSwitcherClosingBeforeSelectionKeepsCard(t *testing.T) {779 func TestSwitcherClosingBeforeSelectionKeepsCard(t *testing.T) {
780- c, _ := switcherCore(t, 3)780+ c, _, _ := switcherCore(t, 3)
781 c.mods = ports.ModSuper781 c.mods = ports.ModSuper
782- c.runBind(context.Background(), ActionSwitchColumnPrev)782+ bind(t, c, ActionSwitchColumnPrev)
783 sw := &c.cur().mon.sw783 sw := &c.cur().mon.sw
784 selected := sw.order[sw.at]784 selected := sw.order[sw.at]
785 c.cur().mon.RemoveWindow(sw.order[0])785 c.cur().mon.RemoveWindow(sw.order[0])
@@ -789,7 +789,7 @@ func TestSwitcherClosingBeforeSelectionKeepsCard(t *testing.T) {
789 }789 }
790 790
791 func TestSwitcherClosingDownToOneColumnClosesIt(t *testing.T) {791 func TestSwitcherClosingDownToOneColumnClosesIt(t *testing.T) {
792- c, _ := switcherCore(t, 2)792+ c, _, _ := switcherCore(t, 2)
793 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))793 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
794 c.switcherTick()794 c.switcherTick()
795 c.cur().mon.RemoveWindow(c.cur().mon.sw.order[1])795 c.cur().mon.RemoveWindow(c.cur().mon.sw.order[1])
@@ -807,7 +807,7 @@ func TestSwitcherClosingDownToOneColumnClosesIt(t *testing.T) {
807 }807 }
808 808
809 func TestSwitcherOutputRemovalCancels(t *testing.T) {809 func TestSwitcherOutputRemovalCancels(t *testing.T) {
810- c, _ := switcherCore(t, 3)810+ c, _, _ := switcherCore(t, 3)
811 c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})811 c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
812 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))812 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
813 c.switcherTick()813 c.switcherTick()
@@ -828,7 +828,7 @@ func TestSwitcherOutputRemovalCancels(t *testing.T) {
828 }828 }
829 829
830 func TestSwitcherFocusMovingToAnotherOutputReopensThere(t *testing.T) {830 func TestSwitcherFocusMovingToAnotherOutputReopensThere(t *testing.T) {
831- c, _ := switcherCore(t, 3)831+ c, _, _ := switcherCore(t, 3)
832 c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})832 c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
833 first := c.cur()833 first := c.cur()
834 for id := WindowID(11); id <= 13; id++ {834 for id := WindowID(11); id <= 13; id++ {
@@ -836,12 +836,12 @@ func TestSwitcherFocusMovingToAnotherOutputReopensThere(t *testing.T) {
836 }836 }
837 indicatorScene(t, c)837 indicatorScene(t, c)
838 c.mods = ports.ModSuper838 c.mods = ports.ModSuper
839- c.runBind(context.Background(), ActionSwitchColumnNext)839+ bind(t, c, ActionSwitchColumnNext)
840 if c.switcher.sc != first {840 if c.switcher.sc != first {
841 t.Fatal("switcher not on the focused screen")841 t.Fatal("switcher not on the focused screen")
842 }842 }
843 c.focusScreen = c.screenIndex("B")843 c.focusScreen = c.screenIndex("B")
844- c.runBind(context.Background(), ActionSwitchColumnNext)844+ bind(t, c, ActionSwitchColumnNext)
845 if first.mon.sw.open {845 if first.mon.sw.open {
846 t.Fatal("old switcher left open")846 t.Fatal("old switcher left open")
847 }847 }
@@ -853,7 +853,7 @@ func TestSwitcherFocusMovingToAnotherOutputReopensThere(t *testing.T) {
853 // With animations on, the cards fly in and the commit flies them back; the853 // 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.854 // windows end where the layout puts them, with no card left.
855 func TestSwitcherAnimatedHoldAndCommit(t *testing.T) {855 func TestSwitcherAnimatedHoldAndCommit(t *testing.T) {
856- c, ic := switcherCore(t, 3)856+ c, ic, _ := switcherCore(t, 3)
857 c.cfg.Animations.On = true857 c.cfg.Animations.On = true
858 target := c.cur().mon.Current().switchOrder()[1]858 target := c.cur().mon.Current().switchOrder()[1]
859 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))859 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
@@ -876,3 +876,227 @@ func TestSwitcherAnimatedHoldAndCommit(t *testing.T) {
876 t.Fatalf("cards %d, focus %d (want %d)", len(scenePreviews(s)), switcherFocus(c), target)876 t.Fatalf("cards %d, focus %d (want %d)", len(scenePreviews(s)), switcherFocus(c), target)
877 }877 }
878 }878 }
879+
880+// --- review fixes ---
881+
882+// twoWorkspaces is a switcher core whose second workspace holds two
883+// windows (10, 11) and is not on screen.
884+func twoWorkspaces(t *testing.T) (*Core, *Workspace) {
885+ t.Helper()
886+ c, _, _ := switcherCore(t, 3)
887+ c.cfg.Floating.Dim = 0.4
888+ m := c.cur().mon
889+ first := m.Current()
890+ m.Focus(1)
891+ m.AddWindow(10)
892+ m.AddWindow(11)
893+ other := m.Current()
894+ m.show(first)
895+ if other == first || m.Current() != first {
896+ t.Fatal("workspaces not set up")
897+ }
898+ indicatorScene(t, c)
899+ return c, other
900+}
901+
902+func TestSwitcherShownDropsWhenAnotherWorkspaceComesOnScreen(t *testing.T) {
903+ c, other := twoWorkspaces(t)
904+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
905+ c.switcherTick()
906+ if s := indicatorScene(t, c); len(scenePreviews(s)) != 3 {
907+ t.Fatalf("cards not shown: %+v", s.Windows)
908+ }
909+ // An xdg-activation request for a window of the other workspace.
910+ if err := c.activate(context.Background(), 10); err != nil {
911+ t.Fatal(err)
912+ }
913+ m := c.cur().mon
914+ if m.Current() != other || m.previewing() {
915+ t.Fatalf("on screen %v, previewing %v", m.Current() == other, m.previewing())
916+ }
917+ s := indicatorScene(t, c)
918+ if len(scenePreviews(s)) != 0 || s.Dim != 0 || s.DimBehind {
919+ t.Fatalf("scene still dimmed or previewing: dim %v previews %+v", s.Dim, scenePreviews(s))
920+ }
921+ if m.sw.open || c.switcher.sc != nil || c.switcher.timerC != nil {
922+ t.Fatal("stale switcher left open")
923+ }
924+ var any bool
925+ for _, p := range c.cur().shownLayout() {
926+ if p.ID == 10 || p.ID == 11 {
927+ x, y := float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2)
928+ if id, _, _ := c.hit(x, y); id != p.ID {
929+ t.Fatalf("hit %d at window %d", id, p.ID)
930+ }
931+ any = true
932+ }
933+ }
934+ if !any {
935+ t.Fatal("workspace windows not laid out")
936+ }
937+ // The next switch opens fresh on the new workspace.
938+ c.mods = ports.ModSuper
939+ bind(t, c, ActionSwitchColumnNext)
940+ if sw := m.sw; !sw.open || sw.ws != other || sw.at != 1 {
941+ t.Fatalf("open %v on other %v at %d", sw.open, sw.ws == other, sw.at)
942+ }
943+}
944+
945+func TestSwitcherStepOnStaleWorkspaceOpensFresh(t *testing.T) {
946+ c, other := twoWorkspaces(t)
947+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
948+ c.switcherTick()
949+ // Shown on screen, then the workspace changes with no publish between.
950+ c.cur().mon.show(other)
951+ if c.cur().mon.previewing() {
952+ t.Fatal("stale cards still count as previews")
953+ }
954+ sendKeys(t, c, tab(ports.ModSuper, true))
955+ sw := c.cur().mon.sw
956+ if !sw.open || sw.ws != other || sw.shown || sw.at != 1 {
957+ t.Fatalf("open %v on other %v shown %v at %d", sw.open, sw.ws == other, sw.shown, sw.at)
958+ }
959+ // A release on a stale switcher commits nothing and closes it.
960+ c.cur().mon.show(c.cur().mon.Workspaces[0])
961+ focus := switcherFocus(c)
962+ sendKeys(t, c, superKey(0, false))
963+ if c.cur().mon.sw.open || c.switcher.sc != nil || switcherFocus(c) != focus {
964+ t.Fatal("stale switcher survived the release")
965+ }
966+}
967+
968+func TestSwitchIgnoredDuringDrag(t *testing.T) {
969+ c, _, _ := switcherCore(t, 3)
970+ start := switcherFocus(c)
971+ d := &dragState{id: start, button: btnLeft}
972+ c.drag = d
973+ c.buttons[btnLeft] = true
974+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
975+ if c.cur().mon.sw.open || c.switcher.sc != nil || c.switcher.timerC != nil {
976+ t.Fatal("switcher opened during a drag")
977+ }
978+ if c.drag != d || switcherFocus(c) != start {
979+ t.Fatal("drag disturbed")
980+ }
981+ sendKeys(t, c, ports.KeyEvent{Keysym: "Escape", Keycode: 1, Mods: ports.ModSuper, Pressed: true})
982+ if c.drag != nil {
983+ t.Fatal("Escape did not cancel the drag")
984+ }
985+}
986+
987+// Another bind closing the shown cards still flies them back: the snapshot
988+// is taken while they are drawn, so the windows move from the card rects.
989+func TestSwitchBindOverShownCardsFliesBack(t *testing.T) {
990+ c, ic, _ := switcherCore(t, 3)
991+ c.cfg.Animations.On = true
992+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
993+ c.switcherTick()
994+ for range 200 {
995+ ic.now = ic.now.Add(16 * time.Millisecond)
996+ if err := c.step(context.Background(), nil); err != nil {
997+ t.Fatal(err)
998+ }
999+ }
1000+ sc := c.cur()
1001+ cards := map[WindowID]Rect{}
1002+ for _, p := range sc.shownLayout() {
1003+ if p.Preview > 0 {
1004+ cards[p.ID] = p.Rect
1005+ }
1006+ }
1007+ if len(cards) != 3 || len(sc.rects) != 0 {
1008+ t.Fatalf("%d cards, %d motions: the cards did not settle", len(cards), len(sc.rects))
1009+ }
1010+ bind(t, c, ActionFocusColumnLeft)
1011+ if c.switching() || sc.mon.sw.shown {
1012+ t.Fatal("the bind left the switcher open")
1013+ }
1014+ settled := map[WindowID]Rect{}
1015+ for _, p := range sc.mon.Layout() {
1016+ settled[p.ID] = p.Rect
1017+ }
1018+ for id, card := range cards {
1019+ rm, ok := sc.rects[id]
1020+ if !ok || !rm.scale {
1021+ t.Fatalf("window %d has no fly-back motion", id)
1022+ }
1023+ if got := rm.apply(settled[id]); got != card {
1024+ t.Fatalf("window %d flies back from %+v, want its card %+v", id, got, card)
1025+ }
1026+ }
1027+}
1028+
1029+func TestSwitcherClosingSelectedWindowPicksColumnsNextWindow(t *testing.T) {
1030+ m := NewMonitor()
1031+ m.SetOutput(300, 200)
1032+ m.SetMaxColumns(3)
1033+ for id := WindowID(1); id <= 4; id++ {
1034+ m.AddWindow(id)
1035+ }
1036+ w := m.Current()
1037+ w.FocusID(3)
1038+ w.ConsumeOrExpel(-1) // columns [1] [2 3] [4]
1039+ if len(w.Columns) != 3 || len(w.Columns[1].Windows) != 2 {
1040+ t.Fatalf("columns %+v", w.Columns)
1041+ }
1042+ w.FocusID(1)
1043+ openSwitcher(m, 0)
1044+ at := slices.IndexFunc(m.sw.order, func(id WindowID) bool { return w.columnOf(id) == 1 })
1045+ m.sw.at = at
1046+ gone := m.sw.order[at]
1047+ other := w.Columns[1].Windows[0]
1048+ if other == gone {
1049+ other = w.Columns[1].Windows[1]
1050+ }
1051+ m.RemoveWindow(gone)
1052+ if len(m.sw.order) != 3 || m.sw.at != at || m.sw.order[at] != other {
1053+ t.Fatalf("order %v at %d, want card %d to stand for %d", m.sw.order, m.sw.at, at, other)
1054+ }
1055+ m.commitSwitcher()
1056+ if id, _ := w.Focused(); id != other {
1057+ t.Fatalf("focus %d, want %d", id, other)
1058+ }
1059+}
1060+
1061+func TestSwitchCommitFocusesTileUnderFloat(t *testing.T) {
1062+ c, _, _ := switcherCore(t, 3)
1063+ m := c.cur().mon
1064+ m.AddFloating(9, 100, 80)
1065+ w := m.Current()
1066+ if !w.floatFocus {
1067+ t.Fatal("float not focused")
1068+ }
1069+ tile := w.Columns[w.Focus].Windows[0]
1070+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1071+ bind(t, c, ActionSwitchColumnPrev) // back to card 0: the column under the float
1072+ if m.sw.at != 0 || m.sw.order[0] != tile {
1073+ t.Fatalf("at %d order %v", m.sw.at, m.sw.order)
1074+ }
1075+ sendKeys(t, c, superKey(0, false))
1076+ if w.floatFocus || switcherFocus(c) != tile {
1077+ t.Fatalf("float focused %v, focus %d, want tile %d", w.floatFocus, switcherFocus(c), tile)
1078+ }
1079+}
1080+
1081+func TestSwitchCommitFollowsToItsOutput(t *testing.T) {
1082+ c, _, _ := switcherCore(t, 3)
1083+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
1084+ first := c.focusScreen
1085+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1086+ want := c.cur().mon.sw.order[1]
1087+ // The pointer crosses to the other output meanwhile.
1088+ c.focusScreen = c.screenIndex("B")
1089+ sendKeys(t, c, superKey(0, false))
1090+ if c.focusScreen != first || switcherFocus(c) != want {
1091+ t.Fatalf("focus screen %d (want %d), focus %d (want %d)", c.focusScreen, first, switcherFocus(c), want)
1092+ }
1093+}
1094+
1095+func TestSwitchUnmodifiedBindThroughKeyEvent(t *testing.T) {
1096+ c, ic, _ := switcherCore(t, 3)
1097+ start := switcherFocus(c)
1098+ sendKeys(t, c, ports.KeyEvent{Keysym: "F9", Keycode: 75, Pressed: true})
1099+ if c.cur().mon.sw.open || len(ic.timers) != 0 || switcherFocus(c) == start {
1100+ t.Fatalf("open %v timers %v focus %d (was %d)", c.cur().mon.sw.open, ic.timers, switcherFocus(c), start)
1101+ }
1102+}

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 23

Tags 10

↑↓ moveenter openesc close