1package core
2
3import (
4 "strconv"
5 "strings"
6
7 "github.com/bnema/neferwl/internal/ports"
8)
9
10type Action string
11
12const (
13 ActionSpawnTerminal Action = "spawn-terminal"
14 ActionFocusColumnLeft Action = "focus-column-left"
15 ActionFocusColumnRight Action = "focus-column-right"
16 ActionFocusWindowUp Action = "focus-window-up"
17 ActionFocusWindowDown Action = "focus-window-down"
18 ActionMoveColumnLeft Action = "move-column-left"
19 ActionMoveColumnRight Action = "move-column-right"
20 ActionCycleColumnWidth Action = "cycle-column-width"
21 ActionMaximizeColumn Action = "maximize-column"
22 ActionToggleFullscreen Action = "toggle-fullscreen"
23 ActionToggleWindowStash Action = "toggle-window-stash"
24 ActionToggleStashVisible Action = "toggle-stash-visible"
25 ActionToggleOverview Action = "toggle-overview"
26 ActionCloseWindow Action = "close-window"
27 ActionQuit Action = "quit"
28 // Workspaces stack vertically; prev/next stop at the ends.
29 ActionFocusWorkspacePrev Action = "focus-workspace-prev"
30 ActionFocusWorkspaceNext Action = "focus-workspace-next"
31 // Move the focused column, or only the focused window, to the previous
32 // or next workspace.
33 ActionMoveColumnToWorkspacePrev Action = "move-column-to-workspace-prev"
34 ActionMoveColumnToWorkspaceNext Action = "move-column-to-workspace-next"
35 ActionMoveWindowToWorkspacePrev Action = "move-window-to-workspace-prev"
36 ActionMoveWindowToWorkspaceNext Action = "move-window-to-workspace-next"
37 // Monitors act on the neighbor in that direction of the global layout
38 // (ADR 011).
39 ActionFocusMonitorLeft Action = "focus-monitor-left"
40 ActionFocusMonitorRight Action = "focus-monitor-right"
41 ActionFocusMonitorUp Action = "focus-monitor-up"
42 ActionFocusMonitorDown Action = "focus-monitor-down"
43 ActionMoveWorkspaceLeft Action = "move-workspace-to-monitor-left"
44 ActionMoveWorkspaceRight Action = "move-workspace-to-monitor-right"
45 ActionMoveWorkspaceToMonitorUp Action = "move-workspace-to-monitor-up"
46 ActionMoveWorkspaceToMonitorDown Action = "move-workspace-to-monitor-down"
47 // Scale steps through the clean scales of the output (see CleanScales).
48 ActionScaleUp Action = "scale-up"
49 ActionScaleDown Action = "scale-down"
50 // Consume or expel the focused window (ADR 016).
51 ActionConsumeOrExpelLeft Action = "consume-or-expel-window-left"
52 ActionConsumeOrExpelRight Action = "consume-or-expel-window-right"
53 // Move the focused window up or down inside its column.
54 ActionMoveWindowUp Action = "move-window-up"
55 ActionMoveWindowDown Action = "move-window-down"
56 // Swap the active numbered workspace with its neighbor.
57 ActionMoveWorkspacePrev Action = "move-workspace-prev"
58 ActionMoveWorkspaceNext Action = "move-workspace-next"
59 // Turn the focused tile into a free floating window, or back.
60 ActionToggleFloating Action = "toggle-floating"
61 // Switch to the column used before this one, further back while the
62 // command key is held (switcher.go).
63 ActionSwitchColumnNext Action = "switch-column-next"
64 ActionSwitchColumnPrev Action = "switch-column-prev"
65)
66
67// monitorDirection returns the direction of the neighbor screen targeted by
68// a focus-monitor or move-workspace-to-monitor action, 0 for other actions.
69func monitorDirection(a Action) direction {
70 switch a {
71 case ActionFocusMonitorLeft, ActionMoveWorkspaceLeft:
72 return dirLeft
73 case ActionFocusMonitorRight, ActionMoveWorkspaceRight:
74 return dirRight
75 case ActionFocusMonitorUp, ActionMoveWorkspaceToMonitorUp:
76 return dirUp
77 case ActionFocusMonitorDown, ActionMoveWorkspaceToMonitorDown:
78 return dirDown
79 }
80 return 0
81}
82
83// Resize axes of ResizeArg.
84const (
85 ResizeWidth = 0
86 ResizeHeight = 1
87)
88
89var resizePrefixes = [...]string{
90 ResizeWidth: "set-column-width ",
91 ResizeHeight: "set-window-height ",
92}
93
94// ResizeArg parses "set-column-width +N" (axis ResizeWidth) and
95// "set-window-height -N%" (axis ResizeHeight). The sign is required and the
96// unit depends on the axis: step is in cells for a width (N from 1 to 16,
97// no %), in percent for a height (N from 1 to 100, with %).
98func ResizeArg(a Action) (axis, step int, ok bool) {
99 for i, prefix := range resizePrefixes {
100 rest, found := strings.CutPrefix(string(a), prefix)
101 if !found {
102 continue
103 }
104 rest = strings.TrimSpace(rest)
105 if i == ResizeWidth {
106 if p, ok := parseResizeStep(rest, maxCells); ok {
107 return i, p, true
108 }
109 return 0, 0, false
110 }
111 if num, found := strings.CutSuffix(rest, "%"); found {
112 if p, ok := parseResizeStep(num, 100); ok {
113 return i, p, true
114 }
115 }
116 return 0, 0, false
117 }
118 return 0, 0, false
119}
120
121// parseResizeStep parses "+N" or "-N" with N from 1 to limit.
122func parseResizeStep(s string, limit int) (int, bool) {
123 if len(s) < 2 || (s[0] != '+' && s[0] != '-') {
124 return 0, false
125 }
126 n, err := strconv.Atoi(s[1:])
127 if err != nil || n < 1 || n > limit || s[1] == '+' || s[1] == '-' {
128 return 0, false
129 }
130 if s[0] == '-' {
131 n = -n
132 }
133 return n, true
134}
135
136// WorkspaceOp is what a numbered workspace action does.
137type WorkspaceOp int
138
139const (
140 FocusWorkspace WorkspaceOp = iota
141 MoveColumnToWorkspace
142 MoveWindowToWorkspace
143)
144
145var workspacePrefixes = [...]string{
146 FocusWorkspace: "focus-workspace ",
147 MoveColumnToWorkspace: "move-column-to-workspace ",
148 MoveWindowToWorkspace: "move-window-to-workspace ",
149}
150
151// WorkspaceArg parses "focus-workspace N", "move-column-to-workspace N" and
152// "move-window-to-workspace N" (N from 1).
153func WorkspaceArg(a Action) (n int, op WorkspaceOp, ok bool) {
154 for i, prefix := range workspacePrefixes {
155 rest, found := strings.CutPrefix(string(a), prefix)
156 if !found {
157 continue
158 }
159 n, err := strconv.Atoi(strings.TrimSpace(rest))
160 if err != nil || n < 1 || n > 99 {
161 return 0, 0, false
162 }
163 return n, WorkspaceOp(i), true
164 }
165 return 0, 0, false
166}
167
168// namedPrefix toggles a named workspace: "workspace dev".
169const namedPrefix = "workspace "
170
171// NamedArg returns the name of a "workspace <name>" action.
172func NamedArg(a Action) (string, bool) {
173 rest, ok := strings.CutPrefix(string(a), namedPrefix)
174 name := strings.TrimSpace(rest)
175 return name, ok && name != "" && !strings.ContainsAny(name, " \t")
176}
177
178// spawnPrefix starts a bind action that runs commands: "spawn fuzzel --flag".
179// They are split by ports.SplitCommands; there is no shell.
180const spawnPrefix = "spawn "
181
182// SpawnCommands returns the commands of a "spawn <cmd>[; <cmd>...]" action.
183func SpawnCommands(a Action) ([][]string, bool) { return prefixedCommands(a, spawnPrefix) }
184
185// stashPrefix starts a bind action that runs commands and opens their
186// windows in the stash: "stash firefox".
187const stashPrefix = "stash "
188
189// StashCommands returns the commands of a "stash <cmd>[; <cmd>...]" action.
190func StashCommands(a Action) ([][]string, bool) { return prefixedCommands(a, stashPrefix) }
191
192func prefixedCommands(a Action, prefix string) ([][]string, bool) {
193 rest, ok := strings.CutPrefix(string(a), prefix)
194 if !ok {
195 return nil, false
196 }
197 commands := ports.SplitCommands(rest)
198 return commands, len(commands) > 0
199}
200
201type Effect struct {
202 Spawn bool
203 Commands [][]string // commands to run; nil means the configured terminal
204 // Stash sends the spawned windows to the stash of the workspace the
205 // action applied to.
206 Stash bool
207 Close WindowID
208 Quit bool
209}
210
211// applyAction runs a bind action. Monitor actions change the focused
212// screen; column focus past the edge column moves to the neighbor screen,
213// else along the layout's workspace axis; everything else applies to the
214// focused screen's monitor.
215func (c *Core) applyAction(a Action) Effect {
216 if e, ok := c.cur().mon.overviewAction(a); ok {
217 return e
218 }
219 // A bind acts on the window on screen: under a covering fullscreen
220 // window, that is the one Focused reports.
221 c.cur().mon.Current().focusCover()
222 // Before the edge moves below: a free float moves on its screen.
223 if c.cur().mon.Current().freeAction(a) {
224 return Effect{}
225 }
226 if name, ok := NamedArg(a); ok && c.bringNamed(name) {
227 return Effect{}
228 }
229 switch a {
230 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:
231 if i := c.neighbor(monitorDirection(a)); i >= 0 {
232 c.focusScreen = i
233 }
234 return Effect{}
235 case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:
236 c.moveWorkspace(monitorDirection(a))
237 return Effect{}
238 case ActionFocusColumnLeft, ActionFocusColumnRight:
239 w := c.cur().mon.Current()
240 dir, d := 1, dirRight
241 if a == ActionFocusColumnLeft {
242 dir, d = -1, dirLeft
243 }
244 // A covering fullscreen window is left for the window on that side;
245 // with none (the workspace, band or stash edge), the move goes past
246 // the edge (see edgeFocus) and fullscreen stays.
247 if w.pinned() && !w.stashOverCover() {
248 if !w.FocusColumn(dir) {
249 c.edgeFocus(dir, d)
250 }
251 return Effect{}
252 }
253 // The first move from a float stays here unless nothing is under it:
254 // a native float leaves for the stash or the columns, and the stash
255 // keeps the focus at its ends.
256 edge := w.leavesBand(w.columnToward(dir)) && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
257 if edge && c.edgeFocus(dir, d) {
258 return Effect{}
259 }
260 case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
261 dir, d := 1, dirRight
262 if a == ActionConsumeOrExpelLeft {
263 dir, d = -1, dirLeft
264 }
265 from := c.cur()
266 if from.mon.Current().ConsumeOrExpel(dir) {
267 from.mon.normalize()
268 return Effect{}
269 }
270 // Full fixed workspace, stacked edge column: one hop to the
271 // neighbor monitor, if any; focus follows the window.
272 i := c.neighbor(d)
273 if i < 0 {
274 return Effect{}
275 }
276 id, ok := from.mon.Current().takeWindow()
277 if !ok {
278 return Effect{}
279 }
280 c.focusScreen = i
281 c.cur().mon.Current().expelTo(id, dir)
282 from.mon.normalize()
283 c.cur().mon.normalize()
284 return Effect{}
285 case ActionMoveColumnLeft, ActionMoveColumnRight:
286 // Past the edge of the workspace or cascade band the column moves to
287 // the neighbor screen, on the side facing this one, and focus
288 // follows it. With no screen there, a cascade column stays in its
289 // band.
290 w := c.cur().mon.Current()
291 dir, d := 1, dirRight
292 if a == ActionMoveColumnLeft {
293 dir, d = -1, dirLeft
294 }
295 if i := c.neighbor(d); w.leavesBand(w.Focus+dir) && i >= 0 {
296 col, ok := w.takeColumn()
297 if !ok {
298 return Effect{}
299 }
300 from := c.cur()
301 c.focusScreen = i
302 to := c.cur().mon.Current()
303 at := 0
304 if d == dirLeft {
305 at = len(to.Columns)
306 }
307 to.receive(col, at)
308 from.mon.normalize()
309 c.cur().mon.normalize()
310 return Effect{}
311 }
312 }
313 return c.cur().mon.Apply(a)
314}
315
316// edgeFocus moves focus-column-left/right (dir -1/1, toward d) past the
317// content edge, in order: to the neighbor monitor on that side; else to the
318// occupied workspace on that side when the workspace axis is horizontal;
319// else to the trailing empty workspace. It reports whether focus moved.
320func (c *Core) edgeFocus(dir int, d direction) bool {
321 m := c.cur().mon
322 if i := c.neighbor(d); i >= 0 {
323 c.focusScreen = i
324 return true
325 }
326 if to := m.occupiedStep(horizontalAxis, dir); to >= 0 {
327 m.Focus(to)
328 return true
329 }
330 return m.stepWorkspace(horizontalAxis, dir)
331}
332
333// Apply runs a bind action on the monitor.
334func (m *Monitor) Apply(a Action) Effect {
335 if e, ok := m.overviewAction(a); ok {
336 return e
337 }
338 if movesToWorkspace(a) {
339 if i, column, ok := m.moveDest(a); ok {
340 m.MoveToWorkspace(i, column)
341 }
342 return Effect{}
343 }
344 if n, _, ok := WorkspaceArg(a); ok {
345 before := m.Current()
346 m.FocusNumber(n)
347 if m.ov.open && m.Current() != before {
348 m.selectRow()
349 }
350 return Effect{}
351 }
352 if name, ok := NamedArg(a); ok {
353 before := m.Current()
354 m.ToggleNamed(name)
355 if m.ov.open && m.Current() != before {
356 m.selectRow()
357 }
358 return Effect{}
359 }
360 switch a {
361 case ActionFocusWindowUp, ActionFocusWindowDown:
362 // Past the column and any on-screen neighbor or demoted float,
363 // move to the next workspace if the layout's workspace axis is
364 // vertical. Cascade workspaces sit side by side: up/down stop at the
365 // first and last band.
366 dir := 1
367 if a == ActionFocusWindowUp {
368 dir = -1
369 }
370 if !m.Current().FocusWindow(dir) {
371 m.stepWorkspace(verticalAxis, dir)
372 }
373 return Effect{}
374 case ActionToggleFullscreen:
375 m.ToggleFullscreen()
376 return Effect{}
377 case ActionToggleWindowStash:
378 m.Current().ToggleWindowStash()
379 return Effect{}
380 case ActionToggleStashVisible:
381 w := m.Current()
382 // An empty stash has nothing to show: open the terminal in it.
383 if len(w.Stash) == 0 && m.stashTerminal {
384 return Effect{Spawn: true, Stash: true}
385 }
386 w.ToggleStashVisible()
387 return Effect{}
388 case ActionToggleOverview:
389 m.ToggleOverview()
390 return Effect{}
391 case ActionMaximizeColumn:
392 // A client that made itself fullscreen (Wine at monitor size) goes
393 // back to its column width; the next press maximizes the column.
394 if w := m.Current(); w.fullscreen != 0 {
395 if id, ok := w.Focused(); ok && id == w.fullscreen {
396 m.ToggleFullscreen()
397 return Effect{}
398 }
399 }
400 case ActionFocusWorkspacePrev, ActionFocusWorkspaceNext:
401 if m.shown == nil && a == ActionFocusWorkspacePrev {
402 m.Focus(m.Active - 1)
403 } else if m.shown == nil {
404 m.Focus(m.Active + 1)
405 }
406 return Effect{}
407 case ActionMoveWorkspacePrev:
408 m.MoveWorkspace(-1)
409 return Effect{}
410 case ActionMoveWorkspaceNext:
411 m.MoveWorkspace(1)
412 return Effect{}
413 }
414 return m.Current().Apply(a)
415}
416
417// movesToWorkspace reports a bind that moves a window to another workspace.
418func movesToWorkspace(a Action) bool {
419 if _, op, ok := WorkspaceArg(a); ok {
420 return op != FocusWorkspace
421 }
422 switch a {
423 case ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext,
424 ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext:
425 return true
426 }
427 return false
428}
429
430// moveDest resolves a move-to-workspace bind to the workspace index it
431// moves to and whether the whole column goes; ok is false when it has
432// nowhere to go. Up and down do nothing on a named workspace.
433func (m *Monitor) moveDest(a Action) (i int, column, ok bool) {
434 if n, op, isArg := WorkspaceArg(a); isArg {
435 return min(max(n-1, 0), len(m.Workspaces)-1), op == MoveColumnToWorkspace, true
436 }
437 if m.shown != nil {
438 return 0, false, false
439 }
440 switch a {
441 case ActionMoveColumnToWorkspacePrev, ActionMoveWindowToWorkspacePrev:
442 return m.Active - 1, a == ActionMoveColumnToWorkspacePrev, m.Active > 0
443 case ActionMoveColumnToWorkspaceNext, ActionMoveWindowToWorkspaceNext:
444 return min(m.Active+1, len(m.Workspaces)-1), a == ActionMoveColumnToWorkspaceNext, true
445 }
446 return 0, false, false
447}
448
449// Apply runs a bind action that only touches this workspace.
450func (w *Workspace) Apply(a Action) Effect {
451 if commands, ok := SpawnCommands(a); ok {
452 return Effect{Spawn: true, Commands: commands}
453 }
454 if commands, ok := StashCommands(a); ok {
455 return Effect{Spawn: true, Stash: true, Commands: commands}
456 }
457 if w.freeAction(a) {
458 return Effect{}
459 }
460 if axis, step, ok := ResizeArg(a); ok {
461 if axis == ResizeWidth {
462 for range max(step, -step) {
463 if step > 0 {
464 w.GrowColumn(false)
465 } else {
466 w.ShrinkColumn()
467 }
468 }
469 } else {
470 w.ResizeRow(step)
471 }
472 return Effect{}
473 }
474 switch a {
475 case ActionSpawnTerminal:
476 return Effect{Spawn: true}
477 case ActionFocusColumnLeft:
478 w.FocusColumn(-1)
479 case ActionFocusColumnRight:
480 w.FocusColumn(1)
481 case ActionFocusWindowUp:
482 w.FocusWindow(-1)
483 case ActionFocusWindowDown:
484 w.FocusWindow(1)
485 case ActionMoveColumnLeft:
486 w.MoveColumn(-1)
487 case ActionMoveColumnRight:
488 w.MoveColumn(1)
489 case ActionMoveWindowUp:
490 w.MoveWindow(-1)
491 case ActionMoveWindowDown:
492 w.MoveWindow(1)
493 case ActionCycleColumnWidth:
494 w.GrowColumn(true)
495 case ActionMaximizeColumn:
496 w.ToggleFullWidth()
497 case ActionToggleFullscreen:
498 w.ToggleFullscreen()
499 case ActionToggleWindowStash:
500 w.ToggleWindowStash()
501 case ActionToggleStashVisible:
502 w.ToggleStashVisible()
503 case ActionToggleFloating:
504 w.ToggleFloating()
505 case ActionCloseWindow:
506 id, _ := w.Focused()
507 return Effect{Close: id}
508 case ActionQuit:
509 return Effect{Quit: true}
510 }
511 return Effect{}
512}