rules.go RawHistoryFolder

1package core
2
3import (
4	"fmt"
5	"regexp"
6	"slices"
7	"strconv"
8
9	"github.com/bnema/neferwl/internal/ports"
10)
11
12// Window rules (rule.<name>.* keys): the app ID of a window that maps for
13// the first time picks its place. Rules are evaluated once, at the first
14// map, in spawnPlacement.place; a later app ID change, a re-map or a config
15// reload moves nothing.
16//
17// A rule never moves the user (ADR 011): it does not take the focus and does
18// not change which workspace or monitor is shown or focused. A window sent to
19// another workspace joins it quietly, like a slot window. The one exception
20// is the workspace on screen of the focused monitor, where the window
21// behaves like any new window.
22
23// rule is one parsed window rule.
24type rule struct {
25	appID    *regexp.Regexp
26	floating *bool
27	// number is the 1-based numbered workspace; workspace the name of a
28	// named one. At most one is set.
29	number    int
30	workspace string
31	monitor   string
32	width     Width // zero: automatic
33}
34
35// ruleEffect is the merge of every rule matching a window: per field, the
36// last rule in config order that sets it wins.
37type ruleEffect struct {
38	floating  *bool
39	number    int
40	workspace string
41	monitor   string
42	width     Width // zero: automatic
43}
44
45func parseRules(rs []ports.WindowRule) ([]rule, error) {
46	out := make([]rule, 0, len(rs))
47	for _, r := range rs {
48		if r.AppID == nil {
49			return nil, fmt.Errorf("rule %s: no app-id", r.Name)
50		}
51		p := rule{appID: r.AppID, floating: r.Floating, monitor: r.Monitor}
52		if r.Workspace != "" {
53			if n, err := strconv.Atoi(r.Workspace); err == nil {
54				if n < 1 {
55					return nil, fmt.Errorf("rule %s: invalid workspace %q", r.Name, r.Workspace)
56				}
57				p.number = n
58			} else {
59				p.workspace = r.Workspace
60			}
61		}
62		if r.Width != "" {
63			width, err := ParseWidth(r.Width)
64			if err != nil {
65				return nil, fmt.Errorf("rule %s: %w", r.Name, err)
66			}
67			p.width = width
68		}
69		out = append(out, p)
70	}
71	return out, nil
72}
73
74// ruleFor merges the rules matching v's app ID; ok is false when none does.
75// A toplevel mapping again after an unmap (a tray app restored) opens as
76// any other window: rules only place its first map.
77func (c *Core) ruleFor(v ports.WindowMapped) (eff ruleEffect, ok bool) {
78	if v.Remap {
79		return eff, false
80	}
81	for i := range c.rules {
82		r := &c.rules[i]
83		if !r.appID.MatchString(v.AppID) {
84			continue
85		}
86		ok = true
87		if r.floating != nil {
88			eff.floating = r.floating
89		}
90		if r.number > 0 || r.workspace != "" {
91			eff.number, eff.workspace = r.number, r.workspace
92		}
93		if r.monitor != "" {
94			eff.monitor = r.monitor
95		}
96		if r.width != (Width{}) {
97			eff.width = r.width
98		}
99	}
100	return eff, ok
101}
102
103// ruleTarget is the screen and workspace a rule sends a window to. A named
104// workspace lives where it lives, whatever the rule's monitor; an unknown one
105// is ignored. A monitor that is not connected is ignored too. Without a
106// workspace the monitor's workspace on screen is the target, and a number is
107// clamped to the workspaces that exist (the last one is the spare empty one),
108// so no empty workspace is created.
109func (c *Core) ruleTarget(eff ruleEffect) (*screen, *Workspace) {
110	if eff.workspace != "" {
111		if sc, w := c.byName(eff.workspace); w != nil {
112			return sc, w
113		}
114	}
115	sc := c.cur()
116	if eff.monitor != "" {
117		if i := slices.IndexFunc(c.screens, func(s *screen) bool { return s.mon.matches(eff.monitor) }); i >= 0 {
118			sc = c.screens[i]
119		}
120	}
121	if eff.number > 0 {
122		return sc, sc.mon.Workspaces[min(eff.number, len(sc.mon.Workspaces))-1]
123	}
124	return sc, sc.mon.Current()
125}
126
127// placeByRule puts a window that maps for the first time where eff says.
128func (c *Core) placeByRule(v ports.WindowMapped, eff ruleEffect) {
129	float := v.Floating
130	if eff.floating != nil {
131		float = *eff.floating
132	}
133	sc, w := c.ruleTarget(eff)
134	// The workspace the user looks at takes a new window as usual; any
135	// other one gets it without a change of focus or view.
136	quiet := sc != c.cur() || w != sc.mon.Current()
137	switch {
138	case float:
139		// A window that already floats keeps the size it maps with; a tile
140		// floated by a rule becomes a free float, core sizing it.
141		w.addFloat(w.ruleFloat(v), quiet)
142	case quiet:
143		w.addColumnQuiet(Column{Windows: []WindowID{v.ID}, Width: eff.width})
144	default:
145		sc.mon.addWindow(v.ID, eff.width)
146		return
147	}
148	sc.mon.normalize()
149}
150
151// ruleFloat is the float of a window a rule floats.
152func (w *Workspace) ruleFloat(v ports.WindowMapped) Float {
153	if v.Floating {
154		return Float{ID: v.ID, W: v.Width, H: v.Height}
155	}
156	f := Float{ID: v.ID, free: true, cx: 0.5, cy: 0.5}
157	if w.Usable.W > 0 && w.Usable.H > 0 {
158		// As makeFree: the size is core's, fixed now. Without an output
159		// yet, floatRect's default (half the usable area) applies.
160		r := w.floatRect(f)
161		f.W, f.H = max(r.W-2*w.border, 1), max(r.H-2*w.border, 1)
162	}
163	return f
164}
165
166// addFloat adds a new float; quiet puts it below the others without the
167// float focus, so the focused window stays the same.
168func (w *Workspace) addFloat(f Float, quiet bool) {
169	if f.ID == 0 || w.has(f.ID) {
170		return
171	}
172	if quiet {
173		// A workspace with nothing else focuses it, so it has a focus when shown.
174		w.floatFocus = w.floatFocus || w.empty()
175		w.Floats = slices.Insert(w.Floats, 0, f)
176		return
177	}
178	w.Floats = append(w.Floats, f)
179	w.floatFocus = true
180}
181
182// addColumnQuiet adds a column where a new window goes, keeping the focus
183// where it is (ADR 011 golden rule).
184func (w *Workspace) addColumnQuiet(col Column) {
185	at := len(w.Columns)
186	if w.cover() != 0 {
187		// As AddWindow: under a covering fullscreen window the column
188		// waits hidden, and a maximized layout stays as it is.
189		if !w.policy().equalCells {
190			at = min(w.Focus+1, at)
191		}
192		w.Columns = slices.Insert(w.Columns, at, col)
193		return
194	}
195	if w.policy().equalCells {
196		if at > 0 {
197			w.unmaximize()
198		}
199	} else if at > 0 {
200		at = w.Focus + 1
201	}
202	w.Columns = slices.Insert(w.Columns, at, col)
203	w.scroll()
204}

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