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