1package core
2
3import (
4 "context"
5 "math"
6 "slices"
7 "time"
8)
9
10// The column switcher is an Alt+Tab for the tiled columns of the workspace on
11// screen. Each workspace keeps the columns it focused, most recent first
12// (recent); the switcher steps through them without moving the focus, and
13// commits the selected one when the command key is released.
14
15// switcherRecentMax caps a workspace's recent list.
16const switcherRecentMax = 64
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.
20const switcherDelay = 150 * time.Millisecond
21
22// switcherState is the switcher of a monitor. A window stands for each
23// column in order, the focused column first. Nothing moves until the commit;
24// shown is set once the cards are drawn.
25type switcherState struct {
26 // ws is the workspace the switcher opened on; nil when closed.
27 ws *Workspace
28 shown bool
29 order []WindowID
30 at int
31 // sizes are the client sizes the cards are scaled from.
32 sizes map[WindowID]Rect
33}
34
35// switcherShown reports whether the switcher's cards are drawn. They belong
36// to the workspace that was on screen when they showed: if another one is on
37// screen now (an activation, the taskbar, an output coming back) the cards
38// are stale and the monitor draws its workspace again.
39func (m *Monitor) switcherShown() bool { return m.sw.shown && m.sw.ws == m.Current() }
40
41// previewing reports whether the monitor draws previews instead of its
42// workspace: the overview, or the switcher's cards.
43func (m *Monitor) previewing() bool { return m.ov.open || m.switcherShown() }
44
45// noteFocus records the focused column as the most recently used. It does
46// nothing while a float or the stash has the focus, and allocates nothing
47// when the focused column is already the latest.
48func (w *Workspace) noteFocus() {
49 if w.floatFocus || w.stashFocused() || w.Focus < 0 || w.Focus >= len(w.Columns) {
50 return
51 }
52 col := w.Columns[w.Focus]
53 id := col.Windows[col.Focus]
54 if len(w.recent) > 0 && slices.Contains(col.Windows, w.recent[0]) {
55 w.recent[0] = id
56 return
57 }
58 w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return v == id || w.columnOf(v) == w.Focus })
59 w.recent = slices.Insert(w.recent, 0, id)
60 if len(w.recent) > switcherRecentMax {
61 w.recent = w.recent[:switcherRecentMax]
62 }
63}
64
65// switchOrder lists one window per column, the focused window of each: the
66// focused column first, then the others by recent use, then those never
67// used by position. It is nil with fewer than two columns.
68func (w *Workspace) switchOrder() []WindowID {
69 n := len(w.Columns)
70 if n < 2 || w.Focus < 0 || w.Focus >= n {
71 return nil
72 }
73 seen := make([]bool, n)
74 order := make([]WindowID, 0, n)
75 add := func(i int) {
76 if seen[i] {
77 return
78 }
79 seen[i] = true
80 c := w.Columns[i]
81 order = append(order, c.Windows[c.Focus])
82 }
83 add(w.Focus)
84 for _, id := range w.recent {
85 if i := w.columnOf(id); i >= 0 {
86 add(i)
87 }
88 }
89 for i := range n {
90 add(i)
91 }
92 return order
93}
94
95// heir is the window of id's column that is not id, or 0: call it before id
96// is removed, then hand it to remove.
97func (s *switcherState) heir(id WindowID) WindowID {
98 if s.ws == nil || !slices.Contains(s.order, id) {
99 return 0
100 }
101 if i := s.ws.columnOf(id); i >= 0 {
102 for _, v := range s.ws.Columns[i].Windows {
103 if v != id {
104 return v
105 }
106 }
107 }
108 return 0
109}
110
111// remove drops a closed window from the open switcher. When its column
112// still has windows (heir, from before the removal) the card stands for the
113// column's new focused window and the selection stays; otherwise the card
114// goes, and the selection stays on the same card (or the one that took its
115// place). With fewer than two columns left there is nothing to switch.
116func (s *switcherState) remove(id, heir WindowID) {
117 if s.ws == nil {
118 return
119 }
120 i := slices.Index(s.order, id)
121 if i < 0 {
122 return
123 }
124 if j := s.ws.columnOf(heir); heir != 0 && j >= 0 {
125 col := s.ws.Columns[j]
126 next := col.Windows[col.Focus]
127 if size, ok := s.sizes[id]; ok {
128 if _, has := s.sizes[next]; !has {
129 s.sizes[next] = size
130 }
131 }
132 s.order[i] = next
133 return
134 }
135 s.order = slices.Delete(s.order, i, i+1)
136 if i < s.at {
137 s.at--
138 }
139 s.at = max(min(s.at, len(s.order)-1), 0)
140 if len(s.order) < 2 {
141 *s = switcherState{}
142 }
143}
144
145// commitSwitcher closes the switcher and focuses the selected column. The
146// column left hands its maximization to the target. It does nothing when the
147// workspace is not on screen any more, the window went, or the selection is
148// the tile already focused (a float or the stash holding the focus does not
149// count: the tile takes it back). It reports whether the selection was still
150// valid on the workspace on screen.
151func (m *Monitor) commitSwitcher() bool {
152 s := m.sw
153 m.sw = switcherState{}
154 if s.ws == nil || s.at < 0 || s.at >= len(s.order) {
155 return false
156 }
157 id, w := s.order[s.at], s.ws
158 if w != m.Current() {
159 return false
160 }
161 i := w.columnOf(id)
162 if i < 0 {
163 return false
164 }
165 if i == w.Focus && !w.floatFocus && !w.stashFocused() {
166 return true
167 }
168 if i != w.Focus && !w.policy().wraps && w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth {
169 w.transferMaximization(i)
170 }
171 // Activate leaves another window's fullscreen and handles dialogs.
172 w.Activate(id)
173 return true
174}
175
176// switchDir is the direction of a switch action, 0 for any other action.
177func switchDir(a Action) int {
178 switch a {
179 case ActionSwitchColumnNext:
180 return 1
181 case ActionSwitchColumnPrev:
182 return -1
183 }
184 return 0
185}
186
187// switching reports whether a switcher is open.
188func (c *Core) switching() bool {
189 return c.switcher.sc != nil && c.switcher.sc.mon.sw.ws != nil
190}
191
192// switcherStale reports whether the open switcher belongs to a workspace
193// that is not on its screen any more.
194func (c *Core) switcherStale() bool {
195 m := c.switcher.sc.mon
196 return m.sw.ws != nil && m.sw.ws != m.Current()
197}
198
199func (c *Core) stopSwitcherTimer() {
200 if c.switcher.timerStop != nil {
201 c.switcher.timerStop()
202 }
203 c.switcher.timerC, c.switcher.timerStop = nil, nil
204}
205
206// cancelSwitcher closes the switcher with the focus unchanged. It only
207// resets state: callers publish.
208func (c *Core) cancelSwitcher() {
209 c.stopSwitcherTimer()
210 if sc := c.switcher.sc; sc != nil {
211 sc.mon.sw = switcherState{}
212 }
213 c.switcher.sc = nil
214}
215
216// commitSwitcher commits the switcher on its own screen and ends it. The
217// keyboard follows the committed window, even if the pointer moved the focus
218// to another output meanwhile.
219func (c *Core) commitSwitcher() {
220 c.stopSwitcherTimer()
221 if sc := c.switcher.sc; sc != nil {
222 if sc.mon.commitSwitcher() {
223 if i := slices.Index(c.screens, sc); i >= 0 {
224 c.focusScreen = i
225 }
226 }
227 }
228 c.switcher.sc = nil
229}
230
231// switchStep steps the switcher by dir (+1 next, -1 previous), opening it
232// on the focused screen first. With the command key held the cards show after
233// switcherDelay and the commit waits for the release; otherwise it commits
234// at once.
235func (c *Core) switchStep(dir int) {
236 sc := c.cur()
237 if c.switching() && (c.switcher.sc != sc || c.switcherStale()) {
238 // The focus moved to another output, or another workspace came on
239 // screen: start over.
240 c.cancelSwitcher()
241 }
242 if sc.mon.ov.open || c.drag != nil {
243 return
244 }
245 m := sc.mon
246 opened := false
247 if !c.switching() {
248 c.cancelSwitcher()
249 w := m.Current()
250 order := w.switchOrder()
251 if order == nil {
252 return
253 }
254 sizes := make(map[WindowID]Rect, len(order))
255 for _, id := range order {
256 if v, ok := c.configures.sent[id]; ok && v.Width > 0 && v.Height > 0 {
257 sizes[id] = Rect{W: v.Width, H: v.Height}
258 }
259 }
260 at := 1
261 if dir < 0 {
262 at = len(order) - 1
263 }
264 m.sw = switcherState{ws: w, order: order, at: at, sizes: sizes}
265 c.switcher.sc = sc
266 opened = true
267 } else {
268 n := len(m.sw.order)
269 m.sw.at = (m.sw.at + dir + n) % n
270 }
271 if held := c.cmdMod != 0 && c.mods&c.cmdMod != 0; !held {
272 c.commitSwitcher()
273 return
274 }
275 if opened {
276 c.switcher.timerC, c.switcher.timerStop = newTimer(c.opts.Clock, switcherDelay)
277 }
278}
279
280// switcherRun runs f, which changes the switcher, between a snapshot and
281// the transition of what the screens showed, then publishes and rehits so
282// the pointer never points at a stale window.
283func (c *Core) switcherRun(ctx context.Context, f func()) error {
284 now := c.now()
285 shots := c.snapshot(now)
286 f()
287 c.transition(shots, now)
288 if err := c.publish(ctx); err != nil {
289 return err
290 }
291 return c.rehit(ctx)
292}
293
294// switchBind runs a switch-column bind.
295func (c *Core) switchBind(ctx context.Context, dir int) error {
296 return c.switcherRun(ctx, func() {
297 c.keyboard.takeBack() // a bind acts on the windows
298 c.switchStep(dir)
299 })
300}
301
302// releaseSwitcher commits the selection once the command key is up.
303func (c *Core) releaseSwitcher(ctx context.Context) error {
304 return c.switcherRun(ctx, c.commitSwitcher)
305}
306
307// abortSwitcher cancels the switcher and publishes the workspace again.
308func (c *Core) abortSwitcher(ctx context.Context) error {
309 return c.switcherRun(ctx, c.cancelSwitcher)
310}
311
312// switcherTick runs when the delay passed with the command key held: the
313// cards show. It reports whether the scene changed.
314func (c *Core) switcherTick() bool {
315 c.switcher.timerC, c.switcher.timerStop = nil, nil
316 sc := c.switcher.sc
317 if !c.switching() {
318 c.switcher.sc = nil
319 return false
320 }
321 m := sc.mon
322 if m.sw.ws != m.Current() {
323 c.cancelSwitcher()
324 return true
325 }
326 now := c.now()
327 shots := c.snapshot(now)
328 m.sw.shown = true
329 c.transition(shots, now)
330 return true
331}
332
333// switcherClick takes a press while the switcher is open. Before the cards
334// show it cancels and lets the press through (handled false). On the cards
335// a press on one commits it, anywhere else cancels; the press and its
336// release are consumed either way.
337func (c *Core) switcherClick(ctx context.Context, button uint32) (handled bool, err error) {
338 sc := c.switcher.sc
339 if !c.switching() {
340 return false, nil
341 }
342 if !sc.mon.sw.shown {
343 c.cancelSwitcher()
344 return false, nil
345 }
346 err = c.switcherRun(ctx, func() {
347 id := overviewIn(sc.shownLayout(), c.cursorX-float64(sc.x), c.cursorY-float64(sc.y))
348 if i := slices.Index(sc.mon.sw.order, id); id != 0 && i >= 0 {
349 sc.mon.sw.at = i
350 c.commitSwitcher()
351 } else {
352 c.cancelSwitcher()
353 }
354 c.buttons[button], c.swallow[button] = true, true
355 })
356 return true, err
357}
358
359// switcherLayout lays out the shown switcher: one card per column in
360// switchOrder, equal boxes in a row centered in the overview area, every
361// window scaled into its box (never up). Like the overview, the selected
362// card gets Focused (so its configure is Activated) while the keyboard stays
363// on the real focus. Every other window is hidden.
364func (m *Monitor) switcherLayout(dst []Placement) []Placement {
365 sw := &m.sw
366 w := sw.ws
367 area := w.overviewArea()
368 n := len(sw.order)
369 for ws := range m.all() {
370 start := len(dst)
371 dst = ws.appendHidden(dst, true)
372 kept := slices.DeleteFunc(dst[start:], func(p Placement) bool { return slices.Contains(sw.order, p.ID) })
373 dst = dst[:start+len(kept)]
374 }
375 if n == 0 {
376 return dst
377 }
378 gap := max(1, area.W*2/100)
379 boxW := max(min(area.W/5, (area.W*9/10-(n-1)*gap)/n), 1)
380 boxH := max(boxW*area.H/max(area.W, 1), 1)
381 x0 := area.X + (area.W-(n*boxW+(n-1)*gap))/2
382 y0 := area.Y + (area.H-boxH)/2
383 for k, id := range sw.order {
384 s := sw.sizes[id]
385 if s.W <= 0 || s.H <= 0 {
386 s = Rect{W: w.Usable.W, H: w.Usable.H}
387 }
388 z := 1.0
389 if s.W > 0 && s.H > 0 {
390 z = math.Min(1, math.Min(float64(boxW)/float64(s.W), float64(boxH)/float64(s.H)))
391 }
392 rw, rh := int(math.Round(float64(s.W)*z)), int(math.Round(float64(s.H)*z))
393 bx := x0 + k*(boxW+gap)
394 dst = append(dst, Placement{
395 ID: id,
396 Rect: Rect{X: bx + (boxW-rw)/2, Y: y0 + (boxH-rh)/2, W: rw, H: rh},
397 Preview: z,
398 Focused: k == sw.at,
399 })
400 }
401 return dst
402}