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