feat/hint-hybrid aed7ca4

action.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close