1package core
2
3import (
4 "strconv"
5 "strings"
6)
7
8type Action string
9
10const (
11 ActionSpawnTerminal Action = "spawn-terminal"
12 ActionFocusColumnLeft Action = "focus-column-left"
13 ActionFocusColumnRight Action = "focus-column-right"
14 ActionFocusWindowUp Action = "focus-window-up"
15 ActionFocusWindowDown Action = "focus-window-down"
16 ActionMoveColumnLeft Action = "move-column-left"
17 ActionMoveColumnRight Action = "move-column-right"
18 ActionCycleColumnWidth Action = "cycle-column-width"
19 ActionMaximizeColumn Action = "maximize-column"
20 ActionToggleFullscreen Action = "toggle-fullscreen"
21 ActionToggleWindowStash Action = "toggle-window-stash"
22 ActionToggleStashVisible Action = "toggle-stash-visible"
23 ActionToggleOverview Action = "toggle-overview"
24 ActionCloseWindow Action = "close-window"
25 ActionQuit Action = "quit"
26 // Workspaces stack vertically; up/down stop at the ends.
27 ActionFocusWorkspaceUp Action = "focus-workspace-up"
28 ActionFocusWorkspaceDown Action = "focus-workspace-down"
29 // Move the focused column, or only the focused window, one workspace
30 // up or down.
31 ActionMoveColumnToWorkspaceUp Action = "move-column-to-workspace-up"
32 ActionMoveColumnToWorkspaceDown Action = "move-column-to-workspace-down"
33 ActionMoveWindowToWorkspaceUp Action = "move-window-to-workspace-up"
34 ActionMoveWindowToWorkspaceDown Action = "move-window-to-workspace-down"
35 // Monitors are ordered left to right (ADR 011).
36 ActionFocusMonitorLeft Action = "focus-monitor-left"
37 ActionFocusMonitorRight Action = "focus-monitor-right"
38 ActionMoveWorkspaceLeft Action = "move-workspace-to-monitor-left"
39 ActionMoveWorkspaceRight Action = "move-workspace-to-monitor-right"
40 // Scale steps through the clean scales of the output (see CleanScales).
41 ActionScaleUp Action = "scale-up"
42 ActionScaleDown Action = "scale-down"
43 // Consume or expel the focused window (ADR 016).
44 ActionConsumeOrExpelLeft Action = "consume-or-expel-window-left"
45 ActionConsumeOrExpelRight Action = "consume-or-expel-window-right"
46 // Move the focused window up or down inside its column.
47 ActionMoveWindowUp Action = "move-window-up"
48 ActionMoveWindowDown Action = "move-window-down"
49 // Swap the active numbered workspace with its neighbor.
50 ActionMoveWorkspaceUp Action = "move-workspace-up"
51 ActionMoveWorkspaceDown Action = "move-workspace-down"
52 // Turn the focused tile into a free floating window, or back.
53 ActionToggleFloating Action = "toggle-floating"
54)
55
56// Resize axes of ResizeArg.
57const (
58 ResizeWidth = 0
59 ResizeHeight = 1
60)
61
62var resizePrefixes = [...]string{
63 ResizeWidth: "set-column-width ",
64 ResizeHeight: "set-window-height ",
65}
66
67// ResizeArg parses "set-column-width +N%" (axis ResizeWidth) and
68// "set-window-height -N%" (axis ResizeHeight); N is 1 to 100 and the sign
69// is required.
70func ResizeArg(a Action) (axis, pct int, ok bool) {
71 for i, prefix := range resizePrefixes {
72 rest, found := strings.CutPrefix(string(a), prefix)
73 if !found {
74 continue
75 }
76 if p, ok := parseResizeStep(strings.TrimSpace(rest)); ok {
77 return i, p, true
78 }
79 return 0, 0, false
80 }
81 return 0, 0, false
82}
83
84// parseResizeStep parses "+N%" or "-N%" with N from 1 to 100.
85func parseResizeStep(s string) (int, bool) {
86 if len(s) < 3 || (s[0] != '+' && s[0] != '-') || s[len(s)-1] != '%' {
87 return 0, false
88 }
89 n, err := strconv.Atoi(s[1 : len(s)-1])
90 if err != nil || n < 1 || n > 100 || s[1] == '+' || s[1] == '-' {
91 return 0, false
92 }
93 if s[0] == '-' {
94 n = -n
95 }
96 return n, true
97}
98
99// WorkspaceOp is what a numbered workspace action does.
100type WorkspaceOp int
101
102const (
103 FocusWorkspace WorkspaceOp = iota
104 MoveColumnToWorkspace
105 MoveWindowToWorkspace
106)
107
108var workspacePrefixes = [...]string{
109 FocusWorkspace: "focus-workspace ",
110 MoveColumnToWorkspace: "move-column-to-workspace ",
111 MoveWindowToWorkspace: "move-window-to-workspace ",
112}
113
114// WorkspaceArg parses "focus-workspace N", "move-column-to-workspace N" and
115// "move-window-to-workspace N" (N from 1).
116func WorkspaceArg(a Action) (n int, op WorkspaceOp, ok bool) {
117 for i, prefix := range workspacePrefixes {
118 rest, found := strings.CutPrefix(string(a), prefix)
119 if !found {
120 continue
121 }
122 n, err := strconv.Atoi(strings.TrimSpace(rest))
123 if err != nil || n < 1 || n > 99 {
124 return 0, 0, false
125 }
126 return n, WorkspaceOp(i), true
127 }
128 return 0, 0, false
129}
130
131// namedPrefix toggles a named workspace: "workspace dev".
132const namedPrefix = "workspace "
133
134// NamedArg returns the name of a "workspace <name>" action.
135func NamedArg(a Action) (string, bool) {
136 rest, ok := strings.CutPrefix(string(a), namedPrefix)
137 name := strings.TrimSpace(rest)
138 return name, ok && name != "" && !strings.ContainsAny(name, " \t")
139}
140
141// spawnPrefix starts a bind action that runs a command: "spawn fuzzel --flag".
142// Arguments are split on whitespace; there is no shell (use "spawn sh -c ...").
143const spawnPrefix = "spawn "
144
145// SpawnArgv returns the command of a "spawn <cmd>" action.
146func SpawnArgv(a Action) ([]string, bool) {
147 rest, ok := strings.CutPrefix(string(a), spawnPrefix)
148 argv := strings.Fields(rest)
149 return argv, ok && len(argv) > 0
150}
151
152type Effect struct {
153 Spawn bool
154 Argv []string // command to run; nil means the configured terminal
155 Close WindowID
156 Quit bool
157}
158
159// applyAction runs a bind action. Monitor actions change the focused
160// screen; column focus past the edge column moves to the neighbor screen;
161// everything else applies to the focused screen's monitor.
162func (c *Core) applyAction(a Action) Effect {
163 if e, ok := c.cur().mon.overviewAction(a); ok {
164 return e
165 }
166 // A bind acts on the window on screen: under a covering fullscreen
167 // window, that is the one Focused reports.
168 c.cur().mon.Current().focusCover()
169 // Before the edge moves below: a free float moves on its screen.
170 if c.cur().mon.Current().freeAction(a) {
171 return Effect{}
172 }
173 if name, ok := NamedArg(a); ok && c.bringNamed(name) {
174 return Effect{}
175 }
176 dir := 0
177 switch a {
178 case ActionFocusMonitorLeft, ActionMoveWorkspaceLeft:
179 dir = -1
180 case ActionFocusMonitorRight, ActionMoveWorkspaceRight:
181 dir = 1
182 }
183 switch a {
184 case ActionFocusMonitorLeft, ActionFocusMonitorRight:
185 if i := c.neighbor(dir); i >= 0 {
186 c.focusScreen = i
187 }
188 return Effect{}
189 case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight:
190 c.moveWorkspace(dir)
191 return Effect{}
192 case ActionFocusColumnLeft, ActionFocusColumnRight:
193 w := c.cur().mon.Current()
194 if a == ActionFocusColumnLeft {
195 dir = -1
196 } else {
197 dir = 1
198 }
199 // A covering fullscreen window is left for the window on that side;
200 // with none (the workspace or stash edge), the move goes to the
201 // neighbor monitor and fullscreen stays.
202 if w.pinned() {
203 if !w.FocusColumn(dir) {
204 if i := c.neighbor(dir); i >= 0 {
205 c.focusScreen = i
206 }
207 }
208 return Effect{}
209 }
210 // The first move from a float stays here unless nothing is under it:
211 // a native float leaves for the stash or the columns, and the stash
212 // keeps the focus at its ends.
213 edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
214 if i := c.neighbor(dir); edge && i >= 0 {
215 c.focusScreen = i
216 return Effect{}
217 }
218 case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
219 dir = 1
220 if a == ActionConsumeOrExpelLeft {
221 dir = -1
222 }
223 from := c.cur()
224 if from.mon.Current().ConsumeOrExpel(dir) {
225 from.mon.normalize()
226 return Effect{}
227 }
228 // Full fixed workspace, stacked edge column: one hop to the
229 // neighbor monitor, if any; focus follows the window.
230 i := c.neighbor(dir)
231 if i < 0 {
232 return Effect{}
233 }
234 id, ok := from.mon.Current().takeWindow()
235 if !ok {
236 return Effect{}
237 }
238 c.focusScreen = i
239 c.cur().mon.Current().expelTo(id, dir)
240 from.mon.normalize()
241 c.cur().mon.normalize()
242 return Effect{}
243 case ActionMoveColumnLeft, ActionMoveColumnRight:
244 // Past the edge the column moves to the neighbor screen, on the
245 // side facing this one, and focus follows it.
246 w := c.cur().mon.Current()
247 dir = 1
248 if a == ActionMoveColumnLeft {
249 dir = -1
250 }
251 edge := (dir < 0 && w.Focus == 0) || (dir > 0 && w.Focus == len(w.Columns)-1)
252 if i := c.neighbor(dir); edge && i >= 0 {
253 col, ok := w.takeColumn()
254 if !ok {
255 return Effect{}
256 }
257 from := c.cur()
258 c.focusScreen = i
259 to := c.cur().mon.Current()
260 at := 0
261 if dir < 0 {
262 at = len(to.Columns)
263 }
264 to.receive(col, at)
265 from.mon.normalize()
266 c.cur().mon.normalize()
267 return Effect{}
268 }
269 }
270 return c.cur().mon.Apply(a)
271}
272
273// Apply runs a bind action on the monitor.
274func (m *Monitor) Apply(a Action) Effect {
275 if e, ok := m.overviewAction(a); ok {
276 return e
277 }
278 if movesToWorkspace(a) {
279 if i, column, ok := m.moveDest(a); ok {
280 m.MoveToWorkspace(i, column)
281 }
282 return Effect{}
283 }
284 if n, _, ok := WorkspaceArg(a); ok {
285 before := m.Current()
286 m.FocusNumber(n)
287 if m.ov.open && m.Current() != before {
288 m.selectRow()
289 }
290 return Effect{}
291 }
292 if name, ok := NamedArg(a); ok {
293 before := m.Current()
294 m.ToggleNamed(name)
295 if m.ov.open && m.Current() != before {
296 m.selectRow()
297 }
298 return Effect{}
299 }
300 switch a {
301 case ActionFocusWindowUp, ActionFocusWindowDown:
302 // Past the column and any on-screen neighbor or demoted float,
303 // move to the next workspace.
304 dir := 1
305 if a == ActionFocusWindowUp {
306 dir = -1
307 }
308 // A hidden workspace is outside the vertical list.
309 if !m.Current().FocusWindow(dir) && m.shown == nil {
310 m.Focus(m.Active + dir)
311 }
312 return Effect{}
313 case ActionToggleFullscreen:
314 m.ToggleFullscreen()
315 return Effect{}
316 case ActionToggleWindowStash:
317 m.Current().ToggleWindowStash()
318 return Effect{}
319 case ActionToggleStashVisible:
320 m.Current().ToggleStashVisible()
321 return Effect{}
322 case ActionToggleOverview:
323 m.ToggleOverview()
324 return Effect{}
325 case ActionMaximizeColumn:
326 // A client that made itself fullscreen (Wine at monitor size) goes
327 // back to its column width; the next press maximizes the column.
328 if w := m.Current(); w.fullscreen != 0 {
329 if id, ok := w.Focused(); ok && id == w.fullscreen {
330 m.ToggleFullscreen()
331 return Effect{}
332 }
333 }
334 case ActionFocusWorkspaceUp, ActionFocusWorkspaceDown:
335 if m.shown == nil && a == ActionFocusWorkspaceUp {
336 m.Focus(m.Active - 1)
337 } else if m.shown == nil {
338 m.Focus(m.Active + 1)
339 }
340 return Effect{}
341 case ActionMoveWorkspaceUp:
342 m.MoveWorkspace(-1)
343 return Effect{}
344 case ActionMoveWorkspaceDown:
345 m.MoveWorkspace(1)
346 return Effect{}
347 }
348 return m.Current().Apply(a)
349}
350
351// movesToWorkspace reports a bind that moves a window to another workspace.
352func movesToWorkspace(a Action) bool {
353 if _, op, ok := WorkspaceArg(a); ok {
354 return op != FocusWorkspace
355 }
356 switch a {
357 case ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown,
358 ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown:
359 return true
360 }
361 return false
362}
363
364// moveDest resolves a move-to-workspace bind to the workspace index it
365// moves to and whether the whole column goes; ok is false when it has
366// nowhere to go. Up and down do nothing on a named workspace.
367func (m *Monitor) moveDest(a Action) (i int, column, ok bool) {
368 if n, op, isArg := WorkspaceArg(a); isArg {
369 return min(max(n-1, 0), len(m.Workspaces)-1), op == MoveColumnToWorkspace, true
370 }
371 if m.shown != nil {
372 return 0, false, false
373 }
374 switch a {
375 case ActionMoveColumnToWorkspaceUp, ActionMoveWindowToWorkspaceUp:
376 return m.Active - 1, a == ActionMoveColumnToWorkspaceUp, m.Active > 0
377 case ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceDown:
378 return min(m.Active+1, len(m.Workspaces)-1), a == ActionMoveColumnToWorkspaceDown, true
379 }
380 return 0, false, false
381}
382
383// Apply runs a bind action that only touches this workspace.
384func (w *Workspace) Apply(a Action) Effect {
385 if argv, ok := SpawnArgv(a); ok {
386 return Effect{Spawn: true, Argv: argv}
387 }
388 if w.freeAction(a) {
389 return Effect{}
390 }
391 if axis, pct, ok := ResizeArg(a); ok {
392 if axis == ResizeWidth {
393 w.ResizeColumn(pct)
394 } else {
395 w.ResizeRow(pct)
396 }
397 return Effect{}
398 }
399 switch a {
400 case ActionSpawnTerminal:
401 return Effect{Spawn: true}
402 case ActionFocusColumnLeft:
403 w.FocusColumn(-1)
404 case ActionFocusColumnRight:
405 w.FocusColumn(1)
406 case ActionFocusWindowUp:
407 w.FocusWindow(-1)
408 case ActionFocusWindowDown:
409 w.FocusWindow(1)
410 case ActionMoveColumnLeft:
411 w.MoveColumn(-1)
412 case ActionMoveColumnRight:
413 w.MoveColumn(1)
414 case ActionMoveWindowUp:
415 w.MoveWindow(-1)
416 case ActionMoveWindowDown:
417 w.MoveWindow(1)
418 case ActionCycleColumnWidth:
419 w.CycleWidth()
420 case ActionMaximizeColumn:
421 w.ToggleFullWidth()
422 case ActionToggleFullscreen:
423 w.ToggleFullscreen()
424 case ActionToggleWindowStash:
425 w.ToggleWindowStash()
426 case ActionToggleStashVisible:
427 w.ToggleStashVisible()
428 case ActionToggleFloating:
429 w.ToggleFloating()
430 case ActionCloseWindow:
431 id, _ := w.Focused()
432 return Effect{Close: id}
433 case ActionQuit:
434 return Effect{Quit: true}
435 }
436 return Effect{}
437}