feat/hints a471fa4

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	cells     int // 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	cells     int // 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.Cells < 0 || r.Cells > maxCells {
63			return nil, fmt.Errorf("rule %s: invalid width %d", r.Name, r.Cells)
64		}
65		p.cells = r.Cells
66		out = append(out, p)
67	}
68	return out, nil
69}
70
71// ruleFor merges the rules matching v's app ID; ok is false when none does.
72// A toplevel mapping again after an unmap (a tray app restored) opens as
73// any other window: rules only place its first map.
74func (c *Core) ruleFor(v ports.WindowMapped) (eff ruleEffect, ok bool) {
75	if v.Remap {
76		return eff, false
77	}
78	for i := range c.rules {
79		r := &c.rules[i]
80		if !r.appID.MatchString(v.AppID) {
81			continue
82		}
83		ok = true
84		if r.floating != nil {
85			eff.floating = r.floating
86		}
87		if r.number > 0 || r.workspace != "" {
88			eff.number, eff.workspace = r.number, r.workspace
89		}
90		if r.monitor != "" {
91			eff.monitor = r.monitor
92		}
93		if r.cells != 0 {
94			eff.cells = r.cells
95		}
96	}
97	return eff, ok
98}
99
100// ruleTarget is the screen and workspace a rule sends a window to. A named
101// workspace lives where it lives, whatever the rule's monitor; an unknown one
102// is ignored. A monitor that is not connected is ignored too. Without a
103// workspace the monitor's workspace on screen is the target, and a number is
104// clamped to the workspaces that exist (the last one is the spare empty one),
105// so no empty workspace is created.
106func (c *Core) ruleTarget(eff ruleEffect) (*screen, *Workspace) {
107	if eff.workspace != "" {
108		if sc, w := c.byName(eff.workspace); w != nil {
109			return sc, w
110		}
111	}
112	sc := c.cur()
113	if eff.monitor != "" {
114		if i := slices.IndexFunc(c.screens, func(s *screen) bool { return s.mon.matches(eff.monitor) }); i >= 0 {
115			sc = c.screens[i]
116		}
117	}
118	if eff.number > 0 {
119		return sc, sc.mon.Workspaces[min(eff.number, len(sc.mon.Workspaces))-1]
120	}
121	return sc, sc.mon.Current()
122}
123
124// placeByRule puts a window that maps for the first time where eff says.
125func (c *Core) placeByRule(v ports.WindowMapped, eff ruleEffect) {
126	float := v.Floating
127	if eff.floating != nil {
128		float = *eff.floating
129	}
130	sc, w := c.ruleTarget(eff)
131	// The workspace the user looks at takes a new window as usual; any
132	// other one gets it without a change of focus or view.
133	quiet := sc != c.cur() || w != sc.mon.Current()
134	switch {
135	case float:
136		// A window that already floats keeps the size it maps with; a tile
137		// floated by a rule becomes a free float, core sizing it.
138		w.addFloat(w.ruleFloat(v), quiet)
139	case quiet:
140		w.addColumnQuiet(Column{Windows: []WindowID{v.ID}, Cells: eff.cells})
141	default:
142		sc.mon.addWindow(v.ID, eff.cells)
143		return
144	}
145	sc.mon.normalize()
146}
147
148// ruleFloat is the float of a window a rule floats.
149func (w *Workspace) ruleFloat(v ports.WindowMapped) Float {
150	if v.Floating {
151		return Float{ID: v.ID, W: v.Width, H: v.Height}
152	}
153	f := Float{ID: v.ID, free: true, cx: 0.5, cy: 0.5}
154	if w.Usable.W > 0 && w.Usable.H > 0 {
155		// As makeFree: the size is core's, fixed now. Without an output
156		// yet, floatRect's default (half the usable area) applies.
157		r := w.floatRect(f)
158		f.W, f.H = max(r.W-2*w.border, 1), max(r.H-2*w.border, 1)
159	}
160	return f
161}
162
163// addFloat adds a new float; quiet puts it below the others without the
164// float focus, so the focused window stays the same.
165func (w *Workspace) addFloat(f Float, quiet bool) {
166	if f.ID == 0 || w.has(f.ID) {
167		return
168	}
169	if quiet {
170		// A workspace with nothing else focuses it, so it has a focus when shown.
171		w.floatFocus = w.floatFocus || w.empty()
172		w.Floats = slices.Insert(w.Floats, 0, f)
173		return
174	}
175	w.Floats = append(w.Floats, f)
176	w.floatFocus = true
177}
178
179// addColumnQuiet adds a column where a new window goes, keeping the focus
180// where it is (ADR 011 golden rule).
181func (w *Workspace) addColumnQuiet(col Column) {
182	at := len(w.Columns)
183	if w.cover() != 0 {
184		// As AddWindow: under a covering fullscreen window the column
185		// waits hidden, and a maximized layout stays as it is.
186		if !w.policy().equalCells {
187			at = min(w.Focus+1, at)
188		}
189		w.Columns = slices.Insert(w.Columns, at, col)
190		return
191	}
192	switch {
193	case w.policy().wraps:
194		// As addColumn: after the focused band, in it when it has room.
195		at, col.BandStart = w.spawnIndex()
196		w.Columns = slices.Insert(w.Columns, at, col)
197		if len(w.Columns) > 1 && at <= w.Focus {
198			w.Focus++
199		}
200		w.scroll()
201		return
202	case w.policy().equalCells:
203		if at > 0 && w.policy().maximizeHides() {
204			w.unmaximize()
205		}
206	case at > 0:
207		at = w.Focus + 1
208	}
209	w.Columns = slices.Insert(w.Columns, at, col)
210	w.scroll()
211}

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