feat/data-dnd e05cb15

action.go RawHistoryFolder

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}

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 25

Tags 10

↑↓ moveenter openesc close