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); N is 1 to 100 and the sign
96// is required.
97func ResizeArg(a Action) (axis, pct int, ok bool) {
98 for i, prefix := range resizePrefixes {
99 rest, found := strings.CutPrefix(string(a), prefix)
100 if !found {
101 continue
102 }
103 if p, ok := parseResizeStep(strings.TrimSpace(rest)); ok {
104 return i, p, true
105 }
106 return 0, 0, false
107 }
108 return 0, 0, false
109}
110
111// parseResizeStep parses "+N%" or "-N%" with N from 1 to 100.
112func parseResizeStep(s string) (int, bool) {
113 if len(s) < 3 || (s[0] != '+' && s[0] != '-') || s[len(s)-1] != '%' {
114 return 0, false
115 }
116 n, err := strconv.Atoi(s[1 : len(s)-1])
117 if err != nil || n < 1 || n > 100 || s[1] == '+' || s[1] == '-' {
118 return 0, false
119 }
120 if s[0] == '-' {
121 n = -n
122 }
123 return n, true
124}
125
126// WorkspaceOp is what a numbered workspace action does.
127type WorkspaceOp int
128
129const (
130 FocusWorkspace WorkspaceOp = iota
131 MoveColumnToWorkspace
132 MoveWindowToWorkspace
133)
134
135var workspacePrefixes = [...]string{
136 FocusWorkspace: "focus-workspace ",
137 MoveColumnToWorkspace: "move-column-to-workspace ",
138 MoveWindowToWorkspace: "move-window-to-workspace ",
139}
140
141// WorkspaceArg parses "focus-workspace N", "move-column-to-workspace N" and
142// "move-window-to-workspace N" (N from 1).
143func WorkspaceArg(a Action) (n int, op WorkspaceOp, ok bool) {
144 for i, prefix := range workspacePrefixes {
145 rest, found := strings.CutPrefix(string(a), prefix)
146 if !found {
147 continue
148 }
149 n, err := strconv.Atoi(strings.TrimSpace(rest))
150 if err != nil || n < 1 || n > 99 {
151 return 0, 0, false
152 }
153 return n, WorkspaceOp(i), true
154 }
155 return 0, 0, false
156}
157
158// namedPrefix toggles a named workspace: "workspace dev".
159const namedPrefix = "workspace "
160
161// NamedArg returns the name of a "workspace <name>" action.
162func NamedArg(a Action) (string, bool) {
163 rest, ok := strings.CutPrefix(string(a), namedPrefix)
164 name := strings.TrimSpace(rest)
165 return name, ok && name != "" && !strings.ContainsAny(name, " \t")
166}
167
168// spawnPrefix starts a bind action that runs commands: "spawn fuzzel --flag".
169// They are split by ports.SplitCommands; there is no shell.
170const spawnPrefix = "spawn "
171
172// SpawnCommands returns the commands of a "spawn <cmd>[; <cmd>...]" action.
173func SpawnCommands(a Action) ([][]string, bool) {
174 rest, ok := strings.CutPrefix(string(a), spawnPrefix)
175 if !ok {
176 return nil, false
177 }
178 commands := ports.SplitCommands(rest)
179 return commands, len(commands) > 0
180}
181
182type Effect struct {
183 Spawn bool
184 Commands [][]string // commands to run; nil means the configured terminal
185 Close WindowID
186 Quit bool
187}
188
189// applyAction runs a bind action. Monitor actions change the focused
190// screen; column focus past the edge column moves to the neighbor screen;
191// everything else applies to the focused screen's monitor.
192func (c *Core) applyAction(a Action) Effect {
193 if e, ok := c.cur().mon.overviewAction(a); ok {
194 return e
195 }
196 // A bind acts on the window on screen: under a covering fullscreen
197 // window, that is the one Focused reports.
198 c.cur().mon.Current().focusCover()
199 // Before the edge moves below: a free float moves on its screen.
200 if c.cur().mon.Current().freeAction(a) {
201 return Effect{}
202 }
203 if name, ok := NamedArg(a); ok && c.bringNamed(name) {
204 return Effect{}
205 }
206 switch a {
207 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:
208 if i := c.neighbor(monitorDirection(a)); i >= 0 {
209 c.focusScreen = i
210 }
211 return Effect{}
212 case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:
213 c.moveWorkspace(monitorDirection(a))
214 return Effect{}
215 case ActionFocusColumnLeft, ActionFocusColumnRight:
216 w := c.cur().mon.Current()
217 dir, d := 1, dirRight
218 if a == ActionFocusColumnLeft {
219 dir, d = -1, dirLeft
220 }
221 // A covering fullscreen window is left for the window on that side;
222 // with none (the workspace or stash edge), the move goes to the
223 // neighbor monitor and fullscreen stays.
224 if w.pinned() && !w.stashOverCover() {
225 if !w.FocusColumn(dir) {
226 if i := c.neighbor(d); i >= 0 {
227 c.focusScreen = i
228 }
229 }
230 return Effect{}
231 }
232 // The first move from a float stays here unless nothing is under it:
233 // a native float leaves for the stash or the columns, and the stash
234 // keeps the focus at its ends.
235 edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
236 if i := c.neighbor(d); edge && i >= 0 {
237 c.focusScreen = i
238 return Effect{}
239 }
240 case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
241 dir, d := 1, dirRight
242 if a == ActionConsumeOrExpelLeft {
243 dir, d = -1, dirLeft
244 }
245 from := c.cur()
246 if from.mon.Current().ConsumeOrExpel(dir) {
247 from.mon.normalize()
248 return Effect{}
249 }
250 // Full fixed workspace, stacked edge column: one hop to the
251 // neighbor monitor, if any; focus follows the window.
252 i := c.neighbor(d)
253 if i < 0 {
254 return Effect{}
255 }
256 id, ok := from.mon.Current().takeWindow()
257 if !ok {
258 return Effect{}
259 }
260 c.focusScreen = i
261 c.cur().mon.Current().expelTo(id, dir)
262 from.mon.normalize()
263 c.cur().mon.normalize()
264 return Effect{}
265 case ActionMoveColumnLeft, ActionMoveColumnRight:
266 // Past the edge the column moves to the neighbor screen, on the
267 // side facing this one, and focus follows it.
268 w := c.cur().mon.Current()
269 d := dirRight
270 if a == ActionMoveColumnLeft {
271 d = dirLeft
272 }
273 edge := (d == dirLeft && w.Focus == 0) || (d == dirRight && w.Focus == len(w.Columns)-1)
274 if i := c.neighbor(d); edge && i >= 0 {
275 col, ok := w.takeColumn()
276 if !ok {
277 return Effect{}
278 }
279 from := c.cur()
280 c.focusScreen = i
281 to := c.cur().mon.Current()
282 at := 0
283 if d == dirLeft {
284 at = len(to.Columns)
285 }
286 to.receive(col, at)
287 from.mon.normalize()
288 c.cur().mon.normalize()
289 return Effect{}
290 }
291 }
292 return c.cur().mon.Apply(a)
293}
294
295// Apply runs a bind action on the monitor.
296func (m *Monitor) Apply(a Action) Effect {
297 if e, ok := m.overviewAction(a); ok {
298 return e
299 }
300 if movesToWorkspace(a) {
301 if i, column, ok := m.moveDest(a); ok {
302 m.MoveToWorkspace(i, column)
303 }
304 return Effect{}
305 }
306 if n, _, ok := WorkspaceArg(a); ok {
307 before := m.Current()
308 m.FocusNumber(n)
309 if m.ov.open && m.Current() != before {
310 m.selectRow()
311 }
312 return Effect{}
313 }
314 if name, ok := NamedArg(a); ok {
315 before := m.Current()
316 m.ToggleNamed(name)
317 if m.ov.open && m.Current() != before {
318 m.selectRow()
319 }
320 return Effect{}
321 }
322 switch a {
323 case ActionFocusWindowUp, ActionFocusWindowDown:
324 // Past the column and any on-screen neighbor or demoted float,
325 // move to the next workspace. Cascade workspaces sit side by side:
326 // up/down stop at the first and last band.
327 dir := 1
328 if a == ActionFocusWindowUp {
329 dir = -1
330 }
331 // A hidden workspace is outside the vertical list.
332 w := m.Current()
333 if !w.FocusWindow(dir) && m.shown == nil && !w.policy().wraps {
334 m.Focus(m.Active + dir)
335 }
336 return Effect{}
337 case ActionToggleFullscreen:
338 m.ToggleFullscreen()
339 return Effect{}
340 case ActionToggleWindowStash:
341 m.Current().ToggleWindowStash()
342 return Effect{}
343 case ActionToggleStashVisible:
344 m.Current().ToggleStashVisible()
345 return Effect{}
346 case ActionToggleOverview:
347 m.ToggleOverview()
348 return Effect{}
349 case ActionMaximizeColumn:
350 // A client that made itself fullscreen (Wine at monitor size) goes
351 // back to its column width; the next press maximizes the column.
352 if w := m.Current(); w.fullscreen != 0 {
353 if id, ok := w.Focused(); ok && id == w.fullscreen {
354 m.ToggleFullscreen()
355 return Effect{}
356 }
357 }
358 case ActionFocusWorkspacePrev, ActionFocusWorkspaceNext:
359 if m.shown == nil && a == ActionFocusWorkspacePrev {
360 m.Focus(m.Active - 1)
361 } else if m.shown == nil {
362 m.Focus(m.Active + 1)
363 }
364 return Effect{}
365 case ActionMoveWorkspacePrev:
366 m.MoveWorkspace(-1)
367 return Effect{}
368 case ActionMoveWorkspaceNext:
369 m.MoveWorkspace(1)
370 return Effect{}
371 }
372 return m.Current().Apply(a)
373}
374
375// movesToWorkspace reports a bind that moves a window to another workspace.
376func movesToWorkspace(a Action) bool {
377 if _, op, ok := WorkspaceArg(a); ok {
378 return op != FocusWorkspace
379 }
380 switch a {
381 case ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext,
382 ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext:
383 return true
384 }
385 return false
386}
387
388// moveDest resolves a move-to-workspace bind to the workspace index it
389// moves to and whether the whole column goes; ok is false when it has
390// nowhere to go. Up and down do nothing on a named workspace.
391func (m *Monitor) moveDest(a Action) (i int, column, ok bool) {
392 if n, op, isArg := WorkspaceArg(a); isArg {
393 return min(max(n-1, 0), len(m.Workspaces)-1), op == MoveColumnToWorkspace, true
394 }
395 if m.shown != nil {
396 return 0, false, false
397 }
398 switch a {
399 case ActionMoveColumnToWorkspacePrev, ActionMoveWindowToWorkspacePrev:
400 return m.Active - 1, a == ActionMoveColumnToWorkspacePrev, m.Active > 0
401 case ActionMoveColumnToWorkspaceNext, ActionMoveWindowToWorkspaceNext:
402 return min(m.Active+1, len(m.Workspaces)-1), a == ActionMoveColumnToWorkspaceNext, true
403 }
404 return 0, false, false
405}
406
407// Apply runs a bind action that only touches this workspace.
408func (w *Workspace) Apply(a Action) Effect {
409 if commands, ok := SpawnCommands(a); ok {
410 return Effect{Spawn: true, Commands: commands}
411 }
412 if w.freeAction(a) {
413 return Effect{}
414 }
415 if axis, pct, ok := ResizeArg(a); ok {
416 if axis == ResizeWidth {
417 w.ResizeColumn(pct)
418 } else {
419 w.ResizeRow(pct)
420 }
421 return Effect{}
422 }
423 switch a {
424 case ActionSpawnTerminal:
425 return Effect{Spawn: true}
426 case ActionFocusColumnLeft:
427 w.FocusColumn(-1)
428 case ActionFocusColumnRight:
429 w.FocusColumn(1)
430 case ActionFocusWindowUp:
431 w.FocusWindow(-1)
432 case ActionFocusWindowDown:
433 w.FocusWindow(1)
434 case ActionMoveColumnLeft:
435 w.MoveColumn(-1)
436 case ActionMoveColumnRight:
437 w.MoveColumn(1)
438 case ActionMoveWindowUp:
439 w.MoveWindow(-1)
440 case ActionMoveWindowDown:
441 w.MoveWindow(1)
442 case ActionCycleColumnWidth:
443 w.CycleWidth()
444 case ActionMaximizeColumn:
445 w.ToggleFullWidth()
446 case ActionToggleFullscreen:
447 w.ToggleFullscreen()
448 case ActionToggleWindowStash:
449 w.ToggleWindowStash()
450 case ActionToggleStashVisible:
451 w.ToggleStashVisible()
452 case ActionToggleFloating:
453 w.ToggleFloating()
454 case ActionCloseWindow:
455 id, _ := w.Focused()
456 return Effect{Close: id}
457 case ActionQuit:
458 return Effect{Quit: true}
459 }
460 return Effect{}
461}