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}