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}