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. Focus past a content edge visits an occupied
216// workspace, then a neighbor screen, then a trailing empty workspace. Vertical
217// edges do this only with a vertical workspace axis; cascade stays within bands.
218// Other actions apply to the focused screen's monitor unless explicitly moving
219// between screens. Screen changes request a flash on the destination output.
220func (c *Core) applyAction(a Action) Effect {
221 if !c.flashOn() {
222 return c.dispatchAction(a)
223 }
224 switch a {
225 case ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveWindowUp, ActionMoveWindowDown,
226 ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionToggleFloating,
227 ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview,
228 ActionFocusColumnLeft, ActionFocusColumnRight, ActionMoveColumnLeft, ActionMoveColumnRight,
229 ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown,
230 ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:
231 default:
232 return c.dispatchAction(a)
233 }
234 // Compare the action's visible result, not publication-time focus: pointer,
235 // activation and mapping changes must never request a flash.
236 beforeScreen := c.cur()
237 beforeWS := beforeScreen.mon.Current()
238 beforeID, _ := beforeScreen.mon.Focused()
239 before := beforeScreen.mon.Layout()
240 wasOverview := beforeScreen.mon.ov.open
241 wasStashed := beforeWS.stashIndex(beforeID) >= 0
242 wasShown := beforeWS.stashShown()
243 defer func() {
244 sc := c.cur()
245 id, _ := sc.mon.Focused()
246 if sc.mon.ov.open {
247 return
248 }
249 if wasOverview {
250 if a == ActionToggleOverview && id != 0 {
251 c.requestFlash(flashWindow, id)
252 }
253 return
254 }
255 // Crossing outputs marks the destination itself, even if it is empty.
256 if sc != beforeScreen {
257 c.requestFlash(flashMonitor, 0)
258 return
259 }
260 level := flashWindow
261 switch a {
262 case ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveWindowUp, ActionMoveWindowDown,
263 ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionToggleFloating:
264 case ActionToggleWindowStash:
265 if !wasStashed {
266 return
267 }
268 case ActionToggleStashVisible:
269 if wasShown || !sc.mon.Current().stashShown() {
270 return
271 }
272 case ActionFocusColumnLeft, ActionFocusColumnRight, ActionMoveColumnLeft, ActionMoveColumnRight:
273 level = flashColumn
274 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown,
275 ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:
276 return
277 default:
278 return
279 }
280 if sc.mon.Current() != beforeWS {
281 return
282 }
283 after := sc.mon.Layout()
284 changed := id != beforeID || !slices.EqualFunc(before, after, func(a, b Placement) bool {
285 return a.ID == b.ID && a.Rect == b.Rect && a.Focused == b.Focused && a.Hidden == b.Hidden && a.Floating == b.Floating
286 })
287 if changed && id != 0 {
288 c.requestFlash(level, id)
289 }
290 }()
291 return c.dispatchAction(a)
292}
293
294func (c *Core) dispatchAction(a Action) Effect {
295 if e, ok := c.cur().mon.overviewAction(a); ok {
296 return e
297 }
298 // A bind acts on the window on screen: under a covering fullscreen
299 // window, that is the one Focused reports.
300 c.cur().mon.Current().focusCover()
301 // Before the edge moves below: a free float moves on its screen.
302 if c.cur().mon.Current().freeAction(a) {
303 return Effect{}
304 }
305 if name, ok := NamedArg(a); ok && c.bringNamed(name) {
306 return Effect{}
307 }
308 switch a {
309 case ActionHint:
310 c.hintRequest()
311 return Effect{}
312 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:
313 if i := c.neighbor(monitorDirection(a)); i >= 0 {
314 c.focusScreen = i
315 }
316 return Effect{}
317 case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:
318 c.moveWorkspace(monitorDirection(a))
319 return Effect{}
320 case ActionFocusColumnLeft, ActionFocusColumnRight:
321 w := c.cur().mon.Current()
322 dir, d := 1, dirRight
323 if a == ActionFocusColumnLeft {
324 dir, d = -1, dirLeft
325 }
326 // A covering fullscreen window is left for the window on that side;
327 // with none (the workspace, band or stash edge), the move goes past
328 // the edge (see edgeFocus) and fullscreen stays.
329 if w.pinned() && !w.stashOverCover() {
330 if !w.FocusColumn(dir) {
331 c.edgeFocus(horizontalAxis, dir, d)
332 }
333 return Effect{}
334 }
335 // The first move from a float stays here unless nothing is under it:
336 // a native float leaves for the stash or the columns, and the stash
337 // keeps the focus at its ends.
338 edge := w.leavesBand(w.columnToward(dir)) && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
339 if edge && c.edgeFocus(horizontalAxis, dir, d) {
340 return Effect{}
341 }
342 case ActionFocusWindowUp, ActionFocusWindowDown:
343 m := c.cur().mon
344 // Cascade's up/down navigation stays within its bands. Let Monitor
345 // handle it unchanged; only vertical workspace axes cross screens.
346 if m.Current().policy().workspace == verticalAxis {
347 dir, d := 1, dirDown
348 if a == ActionFocusWindowUp {
349 dir, d = -1, dirUp
350 }
351 if !m.Current().FocusWindow(dir) {
352 c.edgeFocus(verticalAxis, dir, d)
353 }
354 return Effect{}
355 }
356 case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
357 dir, d := 1, dirRight
358 if a == ActionConsumeOrExpelLeft {
359 dir, d = -1, dirLeft
360 }
361 from := c.cur()
362 if from.mon.Current().ConsumeOrExpel(dir) {
363 from.mon.normalize()
364 return Effect{}
365 }
366 // Full fixed workspace, stacked edge column: one hop to the
367 // neighbor monitor, if any; focus follows the window.
368 i := c.neighbor(d)
369 if i < 0 {
370 return Effect{}
371 }
372 id, ok := from.mon.Current().takeWindow()
373 if !ok {
374 return Effect{}
375 }
376 c.focusScreen = i
377 c.cur().mon.Current().expelTo(id, dir)
378 from.mon.normalize()
379 c.cur().mon.normalize()
380 return Effect{}
381 case ActionMoveColumnLeft, ActionMoveColumnRight:
382 // Past the edge of the workspace or cascade band the column moves to
383 // the neighbor screen, on the side facing this one, and focus
384 // follows it. With no screen there, a cascade column stays in its
385 // band.
386 w := c.cur().mon.Current()
387 dir, d := 1, dirRight
388 if a == ActionMoveColumnLeft {
389 dir, d = -1, dirLeft
390 }
391 if i := c.neighbor(d); w.leavesBand(w.Focus+dir) && i >= 0 {
392 col, ok := w.takeColumn()
393 if !ok {
394 return Effect{}
395 }
396 from := c.cur()
397 c.focusScreen = i
398 to := c.cur().mon.Current()
399 at := 0
400 if d == dirLeft {
401 at = len(to.Columns)
402 }
403 to.receive(col, at)
404 from.mon.normalize()
405 c.cur().mon.normalize()
406 return Effect{}
407 }
408 }
409 return c.cur().mon.Apply(a)
410}
411
412// edgeFocus moves focus past a content edge along axis (dir -1/1, toward d):
413// first to an occupied workspace when axis is the workspace axis, then to the
414// neighbor monitor, then to the trailing empty workspace. It reports whether
415// focus moved. Callers retain responsibility for local content navigation and
416// for restricting which layout axes may cross monitors.
417func (c *Core) edgeFocus(axis layoutAxis, dir int, d direction) bool {
418 m := c.cur().mon
419 if to := m.occupiedStep(axis, dir); to >= 0 {
420 m.Focus(to)
421 return true
422 }
423 if i := c.neighbor(d); i >= 0 {
424 c.focusScreen = i
425 return true
426 }
427 return m.stepWorkspace(axis, dir)
428}
429
430// Apply runs a bind action on this monitor only. Vertical focus edges step
431// workspaces; Core dispatch adds neighboring-screen navigation for those edges.
432func (m *Monitor) Apply(a Action) Effect {
433 if e, ok := m.overviewAction(a); ok {
434 return e
435 }
436 if movesToWorkspace(a) {
437 if i, column, ok := m.moveDest(a); ok {
438 m.MoveToWorkspace(i, column)
439 }
440 return Effect{}
441 }
442 if n, _, ok := WorkspaceArg(a); ok {
443 before := m.Current()
444 m.FocusNumber(n)
445 if m.ov.open && m.Current() != before {
446 m.selectRow()
447 }
448 return Effect{}
449 }
450 if name, ok := NamedArg(a); ok {
451 before := m.Current()
452 m.ToggleNamed(name)
453 if m.ov.open && m.Current() != before {
454 m.selectRow()
455 }
456 return Effect{}
457 }
458 switch a {
459 case ActionFocusWindowUp, ActionFocusWindowDown:
460 // Past the column and any on-screen neighbor or demoted float,
461 // move to the next workspace if the layout's workspace axis is
462 // vertical. Cascade workspaces sit side by side: up/down stop at the
463 // first and last band.
464 dir := 1
465 if a == ActionFocusWindowUp {
466 dir = -1
467 }
468 if !m.Current().FocusWindow(dir) {
469 m.stepWorkspace(verticalAxis, dir)
470 }
471 return Effect{}
472 case ActionToggleFullscreen:
473 m.ToggleFullscreen()
474 return Effect{}
475 case ActionToggleWindowStash:
476 m.Current().ToggleWindowStash()
477 return Effect{}
478 case ActionToggleStashVisible:
479 w := m.Current()
480 // An empty stash has nothing to show: open the terminal in it.
481 if len(w.Stash) == 0 && m.stashTerminal {
482 return Effect{Spawn: true, Stash: true}
483 }
484 w.ToggleStashVisible()
485 return Effect{}
486 case ActionToggleOverview:
487 m.ToggleOverview()
488 return Effect{}
489 case ActionMaximizeColumn:
490 // A client that made itself fullscreen (Wine at monitor size) goes
491 // back to its column width; the next press maximizes the column.
492 if w := m.Current(); w.fullscreen != 0 {
493 if id, ok := w.Focused(); ok && id == w.fullscreen {
494 m.ToggleFullscreen()
495 return Effect{}
496 }
497 }
498 case ActionFocusWorkspacePrev, ActionFocusWorkspaceNext:
499 if m.shown == nil && a == ActionFocusWorkspacePrev {
500 m.Focus(m.Active - 1)
501 } else if m.shown == nil {
502 m.Focus(m.Active + 1)
503 }
504 return Effect{}
505 case ActionMoveWorkspacePrev:
506 m.MoveWorkspace(-1)
507 return Effect{}
508 case ActionMoveWorkspaceNext:
509 m.MoveWorkspace(1)
510 return Effect{}
511 }
512 return m.Current().Apply(a)
513}
514
515// movesToWorkspace reports a bind that moves a window to another workspace.
516func movesToWorkspace(a Action) bool {
517 if _, op, ok := WorkspaceArg(a); ok {
518 return op != FocusWorkspace
519 }
520 switch a {
521 case ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext,
522 ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext:
523 return true
524 }
525 return false
526}
527
528// moveDest resolves a move-to-workspace bind to the workspace index it
529// moves to and whether the whole column goes; ok is false when it has
530// nowhere to go. Up and down do nothing on a named workspace.
531func (m *Monitor) moveDest(a Action) (i int, column, ok bool) {
532 if n, op, isArg := WorkspaceArg(a); isArg {
533 return min(max(n-1, 0), len(m.Workspaces)-1), op == MoveColumnToWorkspace, true
534 }
535 if m.shown != nil {
536 return 0, false, false
537 }
538 switch a {
539 case ActionMoveColumnToWorkspacePrev, ActionMoveWindowToWorkspacePrev:
540 return m.Active - 1, a == ActionMoveColumnToWorkspacePrev, m.Active > 0
541 case ActionMoveColumnToWorkspaceNext, ActionMoveWindowToWorkspaceNext:
542 return min(m.Active+1, len(m.Workspaces)-1), a == ActionMoveColumnToWorkspaceNext, true
543 }
544 return 0, false, false
545}
546
547// Apply runs a bind action that only touches this workspace.
548func (w *Workspace) Apply(a Action) Effect {
549 if commands, ok := SpawnCommands(a); ok {
550 return Effect{Spawn: true, Commands: commands}
551 }
552 if commands, ok := StashCommands(a); ok {
553 return Effect{Spawn: true, Stash: true, Commands: commands}
554 }
555 if w.freeAction(a) {
556 return Effect{}
557 }
558 if axis, step, ok := ResizeArg(a); ok {
559 if axis == ResizeWidth {
560 for range max(step, -step) {
561 if step > 0 {
562 w.GrowColumn(false)
563 } else {
564 w.ShrinkColumn()
565 }
566 }
567 } else {
568 w.ResizeRow(step)
569 }
570 return Effect{}
571 }
572 switch a {
573 case ActionSpawnTerminal:
574 return Effect{Spawn: true}
575 case ActionFocusColumnLeft:
576 w.FocusColumn(-1)
577 case ActionFocusColumnRight:
578 w.FocusColumn(1)
579 case ActionFocusWindowUp:
580 w.FocusWindow(-1)
581 case ActionFocusWindowDown:
582 w.FocusWindow(1)
583 case ActionMoveColumnLeft:
584 w.MoveColumn(-1)
585 case ActionMoveColumnRight:
586 w.MoveColumn(1)
587 case ActionMoveWindowUp:
588 w.MoveWindow(-1)
589 case ActionMoveWindowDown:
590 w.MoveWindow(1)
591 case ActionCycleColumnWidth:
592 w.GrowColumn(true)
593 case ActionMaximizeColumn:
594 w.ToggleFullWidth()
595 case ActionToggleFullscreen:
596 w.ToggleFullscreen()
597 case ActionToggleWindowStash:
598 w.ToggleWindowStash()
599 case ActionToggleStashVisible:
600 w.ToggleStashVisible()
601 case ActionToggleFloating:
602 w.ToggleFloating()
603 case ActionCloseWindow:
604 id, _ := w.Focused()
605 return Effect{Close: id}
606 case ActionQuit:
607 return Effect{Quit: true}
608 }
609 return Effect{}
610}