1package core
2
3import (
4 "context"
5 "slices"
6
7 "github.com/bnema/neferwl/internal/ports"
8)
9
10// Pointer drag: cmd + left button moves the window under the pointer,
11// cmd + right button resizes a float; xdg_toplevel.move/resize start the
12// same drag with the held button. A float follows the pointer. A tile
13// stays in place until the button is released, and NeferWL draws where
14// it lands (Scene.DropHints):
15//
16// - top or bottom quarter of a window: stack above or below it;
17// - middle of a window: swap the columns (a stacked window becomes a
18// new column there);
19// - scroll overflow, the gap between two columns: a new column there.
20//
21// The client gets no pointer events during the drag; Escape cancels it.
22
23const (
24 btnLeft = 0x110
25 btnRight = 0x111
26 // dropBand is the share of a window's height, top and bottom, that
27 // stacks above or below it.
28 dropBandDiv = 4
29 // gapPad is the least half-width of a gap's insert zone, logical px.
30 gapPad = 12
31 // edgeScroll is the distance from the usable edge, logical px, that
32 // scrolls a scroll-overflow workspace during a tile drag, at most
33 // once per edgeScrollMsec.
34 edgeScroll = 32
35 edgeScrollMsec = 250
36)
37
38type dropKind int
39
40const (
41 dropNone dropKind = iota
42 dropAbove
43 dropBelow
44 dropSwap
45 dropInsert
46)
47
48// dropTarget is where a dragged tile lands. anchor is the hovered window
49// (above, below, swap) or a window of the column the new one goes before
50// (insert; 0 is the end).
51type dropTarget struct {
52 kind dropKind
53 screen *screen
54 ws *Workspace
55 anchor WindowID
56 hints []Rect
57}
58
59type dragState struct {
60 id WindowID
61 button uint32
62 resize bool
63 edges ports.ResizeEdges
64 // start is the cursor at the press, global; from is the float as it
65 // was, restored by Escape; rect is its place then.
66 start [2]float64
67 from Float
68 rect Rect
69 float bool
70 target dropTarget
71 // scrolled is the time of the last edge scroll.
72 scrolled uint32
73}
74
75// draggable returns the screen and workspace of a window a drag may
76// take: a tile or a native or free float of the workspace on screen,
77// not fullscreen, outside the overview.
78func (c *Core) draggable(id WindowID) (*screen, *Workspace, bool) {
79 s, w := c.screenOf(id)
80 if s == nil || w != s.mon.Current() || s.mon.ov.open || id == w.fullscreen || w.cover() != 0 {
81 return nil, nil, false
82 }
83 if w.floatIndex(id) < 0 && w.columnOf(id) < 0 {
84 return nil, nil, false
85 }
86 return s, w, true
87}
88
89// startDrag starts a cmd + button drag on the window under the pointer.
90// It reports whether it did: the press is then not forwarded.
91func (c *Core) startDrag(ctx context.Context, v ports.PointerButton) (bool, error) {
92 if !v.Pressed || c.drag != nil || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 {
93 return false, nil
94 }
95 if v.Button != btnLeft && v.Button != btnRight {
96 return false, nil
97 }
98 id := c.pointer
99 if id == 0 || c.keyboard.inhibited(c.keyboardFocus()) {
100 return false, nil
101 }
102 _, w, ok := c.draggable(id)
103 // Tiles only move; resizing them with the pointer is not supported.
104 if !ok || v.Button == btnRight && w.floatIndex(id) < 0 {
105 return false, nil
106 }
107 var edges ports.ResizeEdges
108 if v.Button == btnRight {
109 edges = ports.ResizeRight | ports.ResizeBottom
110 }
111 return true, c.beginDrag(ctx, id, v.Button, v.Button == btnRight, edges)
112}
113
114// held is the number of held buttons a client saw pressed: swallowed
115// ones stay in c.buttons only for the security gate.
116func (c *Core) held() int {
117 n := 0
118 for b := range c.buttons {
119 if !c.swallow[b] {
120 n++
121 }
122 }
123 return n
124}
125
126// clientDrag starts a drag for xdg_toplevel.move/resize: the window must
127// hold the pointer grab of a held button.
128func (c *Core) clientDrag(ctx context.Context, v ports.WindowMoveRequest) error {
129 // The adapter checked the serial of the last press: that button drags.
130 button := c.lastButton
131 if c.drag != nil || c.grab != v.ID || !c.buttons[button] || c.swallow[button] {
132 return nil
133 }
134 _, w, ok := c.draggable(v.ID)
135 if !ok || v.Resize && (w.floatIndex(v.ID) < 0 || v.Edges == 0) {
136 return nil
137 }
138 // Other held buttons release without a client to tell.
139 for b := range c.buttons {
140 if b != button {
141 c.swallow[b] = true
142 }
143 }
144 return c.beginDrag(ctx, v.ID, button, v.Resize, v.Edges)
145}
146
147func (c *Core) beginDrag(ctx context.Context, id WindowID, button uint32, resize bool, edges ports.ResizeEdges) error {
148 if err := c.dismissGrabs(ctx, id); err != nil {
149 return err
150 }
151 s, w, ok := c.draggable(id)
152 if !ok {
153 return nil
154 }
155 // Click raises a float above the columns: Escape keeps that.
156 w.Click(id)
157 d := &dragState{id: id, button: button, resize: resize, edges: edges, start: [2]float64{c.cursorX, c.cursorY}}
158 if i := w.floatIndex(id); i >= 0 {
159 d.float, d.from, d.rect = true, w.Floats[i], w.floatRect(w.Floats[i])
160 }
161 c.drag = d
162 c.buttons[button] = true
163 c.focusScreen = c.screenIndex(s.name())
164 c.grab = 0
165 if c.pointer != 0 {
166 c.pointer = 0
167 if err := c.command(ctx, ports.PointerFocus{}); err != nil {
168 return err
169 }
170 }
171 return c.publish(ctx)
172}
173
174// dragMotion follows the cursor: a float moves or resizes, a tile shows
175// where it would land.
176func (c *Core) dragMotion(ctx context.Context, timeMsec uint32) error {
177 d := c.drag
178 _, w, ok := c.draggable(d.id)
179 if !ok {
180 c.abortDrag()
181 return c.publish(ctx)
182 }
183 dx, dy := int(c.cursorX-d.start[0]), int(c.cursorY-d.start[1])
184 if d.float {
185 i := w.floatIndex(d.id)
186 if i < 0 {
187 c.abortDrag()
188 return c.publish(ctx)
189 }
190 f := &w.Floats[i]
191 w.makeFree(f)
192 if d.resize {
193 r := resized(d.rect, d.edges, dx, dy, 2*w.border+freeMinSize, w.Usable)
194 f.W, f.H = r.W-2*w.border, r.H-2*w.border
195 w.moveFree(f, r, 0, 0)
196 } else {
197 w.moveFree(f, d.rect, dx, dy)
198 }
199 return c.publish(ctx)
200 }
201 scrolled := c.edgeScroll(timeMsec)
202 t := c.dropAt(d.id, c.cursorX, c.cursorY)
203 if !scrolled && t.kind == d.target.kind && t.anchor == d.target.anchor && t.ws == d.target.ws && slices.Equal(t.hints, d.target.hints) {
204 return nil
205 }
206 d.target = t
207 return c.publish(ctx)
208}
209
210// resized is r with the edges moved by dx, dy, at least min wide and
211// high, inside area u.
212func resized(r Rect, edges ports.ResizeEdges, dx, dy, least int, u Rect) Rect {
213 left, top, right, bottom := r.X, r.Y, r.X+r.W, r.Y+r.H
214 if edges&ports.ResizeLeft != 0 {
215 left = min(max(left+dx, u.X), right-least)
216 }
217 if edges&ports.ResizeRight != 0 {
218 right = max(min(right+dx, u.X+u.W), left+least)
219 }
220 if edges&ports.ResizeTop != 0 {
221 top = min(max(top+dy, u.Y), bottom-least)
222 }
223 if edges&ports.ResizeBottom != 0 {
224 bottom = max(min(bottom+dy, u.Y+u.H), top+least)
225 }
226 return Rect{X: left, Y: top, W: right - left, H: bottom - top}
227}
228
229// endDrag drops the window on release: a tile lands on its target.
230func (c *Core) endDrag(ctx context.Context) error {
231 d := c.drag
232 c.drag = nil
233 if !d.float {
234 if t := c.dropAt(d.id, c.cursorX, c.cursorY); t.kind != dropNone {
235 c.drop(d.id, t)
236 // Slots and guests follow the windows, as after a bind.
237 if err := c.workspaceVisible(ctx, false); err != nil {
238 return err
239 }
240 }
241 }
242 if err := c.publish(ctx); err != nil {
243 return err
244 }
245 return c.rehit(ctx)
246}
247
248// cancelDrag puts a dragged float back; a tile never moved.
249func (c *Core) cancelDrag() {
250 d := c.drag
251 if d.float {
252 if _, w := c.screenOf(d.id); w != nil {
253 if i := w.floatIndex(d.id); i >= 0 {
254 w.Floats[i] = d.from
255 }
256 }
257 }
258 c.abortDrag()
259}
260
261// abortDrag ends the drag where it is; the held button's release is
262// dropped.
263func (c *Core) abortDrag() {
264 if c.drag != nil {
265 c.swallow[c.drag.button] = true
266 c.drag = nil
267 }
268}
269
270// dropAt is the drop target under the global point x, y.
271func (c *Core) dropAt(id WindowID, x, y float64) dropTarget {
272 o, ok := c.layout().At(x, y)
273 if !ok {
274 return dropTarget{}
275 }
276 sc := c.screens[c.screenIndex(o.Info.Name)]
277 m := sc.mon
278 lx, ly := int(x)-o.X, int(y)-o.Y
279 w := m.Current()
280 if m.ov.open || w.pinned() || !m.frameHas(float64(lx), float64(ly)) {
281 return dropTarget{}
282 }
283 t := w.dropIn(id, lx, ly, max(4*c.cfg.Border.Width, 4))
284 t.screen, t.ws = sc, w
285 return t
286}
287
288// visibleColumn is a column on screen and the bounds of its windows.
289type visibleColumn struct {
290 index int
291 rect Rect
292}
293
294// dropIn resolves the drop of window id at the output-local point x, y,
295// hints t logical px thick.
296func (w *Workspace) dropIn(id WindowID, x, y, t int) dropTarget {
297 layout := w.Layout()
298 for _, p := range slices.Backward(layout) {
299 // Floats and the stash are over the tiles: nothing lands there.
300 if p.Floating && !p.Hidden && !p.Below && p.ID != id && contains(p.Rect, x, y) {
301 return dropTarget{}
302 }
303 }
304 src, srcRow := w.columnOf(id), -1
305 if src >= 0 {
306 srcRow = slices.Index(w.Columns[src].Windows, id)
307 }
308 lone := src >= 0 && len(w.Columns[src].Windows) == 1
309 var cols []visibleColumn
310 for _, p := range layout {
311 if p.Floating || p.Hidden || !p.Rect.Overlaps(w.Output) {
312 continue
313 }
314 ci := w.columnOf(p.ID)
315 if ci < 0 {
316 continue
317 }
318 if n := len(cols); n > 0 && cols[n-1].index == ci {
319 cols[n-1].rect = union(cols[n-1].rect, p.Rect)
320 } else {
321 cols = append(cols, visibleColumn{index: ci, rect: p.Rect})
322 }
323 }
324 if len(w.Columns) == 0 || src < 0 && len(cols) == 0 {
325 if !contains(w.Usable, x, y) {
326 return dropTarget{}
327 }
328 g := w.gap()
329 r := Rect{X: w.Usable.X + g, Y: w.Usable.Y + g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)}
330 return dropTarget{kind: dropInsert, hints: outline(r, t)}
331 }
332 if w.Overflow != OverflowFixed {
333 if tgt, ok := w.gapDrop(cols, id, src, lone, x, y, t); ok {
334 return tgt
335 }
336 }
337 for _, p := range layout {
338 if p.Floating || p.Hidden || !contains(p.Rect, x, y) {
339 continue
340 }
341 ci := w.columnOf(p.ID)
342 if ci < 0 {
343 continue
344 }
345 r := p.Rect
346 band := max(r.H/dropBandDiv, 1)
347 row := slices.Index(w.Columns[ci].Windows, p.ID)
348 switch {
349 case y < r.Y+band:
350 // Above the window just below it, or above itself: no move.
351 if p.ID == id || ci == src && row == srcRow+1 {
352 return dropTarget{}
353 }
354 return dropTarget{kind: dropAbove, anchor: p.ID, hints: []Rect{{X: r.X, Y: r.Y, W: r.W, H: min(t, r.H)}}}
355 case y >= r.Y+r.H-band:
356 if p.ID == id || ci == src && row == srcRow-1 {
357 return dropTarget{}
358 }
359 return dropTarget{kind: dropBelow, anchor: p.ID, hints: []Rect{{X: r.X, Y: r.Y + r.H - min(t, r.H), W: r.W, H: min(t, r.H)}}}
360 default:
361 if ci == src {
362 return dropTarget{}
363 }
364 for _, vc := range cols {
365 if vc.index == ci {
366 return dropTarget{kind: dropSwap, anchor: p.ID, hints: outline(vc.rect, t)}
367 }
368 }
369 return dropTarget{}
370 }
371 }
372 return dropTarget{}
373}
374
375// gapDrop is the insert zone of scroll overflow: the gaps between the
376// columns on screen and beside the outer ones.
377func (w *Workspace) gapDrop(cols []visibleColumn, id WindowID, src int, lone bool, x, y, t int) (dropTarget, bool) {
378 pad := max(w.gap()/2, gapPad)
379 for i := 0; i <= len(cols); i++ {
380 var mid, at int
381 var ref Rect
382 switch {
383 case i == 0:
384 ref = cols[0].rect
385 mid, at = ref.X-w.gap()/2, cols[0].index
386 case i == len(cols):
387 ref = cols[i-1].rect
388 mid, at = ref.X+ref.W+w.gap()/2, cols[i-1].index+1
389 default:
390 a, b := cols[i-1].rect, cols[i].rect
391 ref = union(a, b)
392 mid, at = (a.X+a.W+b.X)/2, cols[i].index
393 }
394 if x < mid-pad || x >= mid+pad || y < ref.Y || y >= ref.Y+ref.H {
395 continue
396 }
397 // A lone column next to its own place does not move.
398 if lone && (at == src || at == src+1) {
399 return dropTarget{}, true
400 }
401 var anchor WindowID
402 if at < len(w.Columns) {
403 anchor = w.Columns[at].Windows[0]
404 if anchor == id {
405 anchor = w.Columns[at].Windows[1]
406 }
407 }
408 hint := Rect{X: mid - t/2, Y: ref.Y, W: t, H: ref.H}
409 return dropTarget{kind: dropInsert, anchor: anchor, hints: []Rect{hint}}, true
410 }
411 return dropTarget{}, false
412}
413
414// contains reports whether r holds the point x, y.
415func contains(r Rect, x, y int) bool {
416 return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H
417}
418
419// union is the smallest rect holding a and b.
420func union(a, b Rect) Rect {
421 x, y := min(a.X, b.X), min(a.Y, b.Y)
422 return Rect{X: x, Y: y, W: max(a.X+a.W, b.X+b.W) - x, H: max(a.Y+a.H, b.Y+b.H) - y}
423}
424
425// outline is the four sides of r, t thick, inside it.
426func outline(r Rect, t int) []Rect {
427 t = min(t, r.W/2, r.H/2)
428 if t <= 0 {
429 return nil
430 }
431 return []Rect{
432 {X: r.X, Y: r.Y, W: r.W, H: t},
433 {X: r.X, Y: r.Y + r.H - t, W: r.W, H: t},
434 {X: r.X, Y: r.Y + t, W: t, H: r.H - 2*t},
435 {X: r.X + r.W - t, Y: r.Y + t, W: t, H: r.H - 2*t},
436 }
437}
438
439// drop moves tile id to target t, possibly on another workspace or
440// screen; focus follows it.
441func (c *Core) drop(id WindowID, t dropTarget) {
442 from, src := c.screenOf(id)
443 if from == nil || t.ws == nil || src.columnOf(id) < 0 {
444 return
445 }
446 w := t.ws
447 ci := src.columnOf(id)
448 lone := len(src.Columns[ci].Windows) == 1
449 if t.kind == dropSwap && w == src && lone {
450 to := w.columnOf(t.anchor)
451 // Their fields move with them, as MoveColumn.
452 w.Columns[ci], w.Columns[to] = w.Columns[to], w.Columns[ci]
453 w.FocusID(id)
454 } else {
455 width := Width{}
456 if lone {
457 width = src.Columns[ci].Width
458 }
459 srcIndex := ci
460 src.dropSlotOf(id)
461 src.RemoveWindow(id)
462 col := Column{Windows: []WindowID{id}, Width: width}
463 switch t.kind {
464 case dropAbove, dropBelow:
465 ai := w.columnOf(t.anchor)
466 if ai < 0 {
467 w.receive(col, -1)
468 break
469 }
470 tc := &w.Columns[ai]
471 row := slices.Index(tc.Windows, t.anchor)
472 if t.kind == dropBelow {
473 row++
474 }
475 if row == 0 {
476 // The slot follows the first window.
477 tc.Slot = 0
478 }
479 tc.Windows = slices.Insert(tc.Windows, row, id)
480 tc.Shares = nil
481 w.Activate(id)
482 default:
483 at, anchorCol := len(w.Columns), -1
484 if t.anchor != 0 {
485 if ai := w.columnOf(t.anchor); ai >= 0 {
486 at, anchorCol = ai, ai
487 // A stacked window dropped on a column right of its
488 // own lands after it.
489 if t.kind == dropSwap && w == src && ai >= srcIndex {
490 at = ai + 1
491 }
492 }
493 }
494 if w.full() {
495 // No room for a column: it joins the outlined one.
496 i := min(at, len(w.Columns)-1)
497 if anchorCol >= 0 {
498 i = anchorCol
499 }
500 w.Columns[i].Windows = append(w.Columns[i].Windows, id)
501 w.Columns[i].Shares = nil
502 w.Activate(id)
503 } else {
504 w.receive(col, at)
505 }
506 }
507 }
508 from.mon.normalize()
509 t.screen.mon.normalize()
510 c.focusScreen = c.screenIndex(t.screen.name())
511}
512
513// edgeScroll scrolls a scroll-overflow workspace by one column when a
514// tile drag moves the pointer against its usable edge. It reports whether
515// the view moved.
516func (c *Core) edgeScroll(timeMsec uint32) bool {
517 o, ok := c.layout().At(c.cursorX, c.cursorY)
518 if !ok || timeMsec-c.drag.scrolled < edgeScrollMsec && c.drag.scrolled != 0 {
519 return false
520 }
521 w := c.screens[c.screenIndex(o.Info.Name)].mon.Current()
522 if w.Overflow == OverflowFixed || len(w.Columns) == 0 || w.pinned() {
523 return false
524 }
525 lx := int(c.cursorX) - o.X
526 dir := 0
527 switch {
528 case lx < w.Usable.X+edgeScroll:
529 dir = -1
530 case lx >= w.Usable.X+w.Usable.W-edgeScroll:
531 dir = 1
532 default:
533 return false
534 }
535 if !w.scrollBy(dir) {
536 return false
537 }
538 c.drag.scrolled = max(timeMsec, 1)
539 return true
540}
541
542// scrollBy moves the view one column toward dir, within the row; the
543// focus stays. It reports whether the view moved.
544func (w *Workspace) scrollBy(dir int) bool {
545 g := w.gap()
546 n := len(w.Columns)
547 maxView := max(w.columnX(n-1)+w.columnWidth(n-1)+g-(w.Usable.X+w.Usable.W), 0)
548 view := w.ViewX
549 if dir < 0 {
550 // The first column not fully on screen at the left.
551 for i := n - 1; i >= 0; i-- {
552 if w.columnX(i)-view < w.Usable.X+g {
553 view = w.columnX(i) - w.Usable.X - g
554 break
555 }
556 }
557 } else {
558 for i := range n {
559 if w.columnX(i)+w.columnWidth(i)-view > w.Usable.X+w.Usable.W-g {
560 view = w.columnX(i) + w.columnWidth(i) + g - w.Usable.X - w.Usable.W
561 break
562 }
563 }
564 }
565 view = min(max(view, 0), maxView)
566 if view == w.ViewX {
567 return false
568 }
569 w.stopSlide()
570 w.ViewX = view
571 return true
572}
573
574// makeFree turns native float f into a free float where it is now.
575func (w *Workspace) makeFree(f *Float) {
576 if f.free {
577 return
578 }
579 r := w.floatRect(*f)
580 if f.W <= 0 || f.H <= 0 {
581 f.W, f.H = max(r.W-2*w.border, 1), max(r.H-2*w.border, 1)
582 }
583 f.free = true
584 w.moveFree(f, r, 0, 0)
585}