v0.10.0 d5d679d

drag.go RawHistoryFolder

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 c.synthetic || !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.requestFlash(flashWindow, d.id)
247			c.transition(shots, now)
248			// Slots and guests follow the windows, as after a bind.
249			if err := c.workspaceVisible(ctx, false); err != nil {
250				return err
251			}
252		}
253	}
254	if err := c.publish(ctx); err != nil {
255		return err
256	}
257	return c.rehit(ctx)
258}
259
260// cancelDrag puts a dragged float back; a tile never moved.
261func (c *Core) cancelDrag() {
262	d := c.drag
263	if d.float {
264		if _, w := c.screenOf(d.id); w != nil {
265			if i := w.floatIndex(d.id); i >= 0 {
266				w.Floats[i] = d.from
267			}
268		}
269	}
270	c.abortDrag()
271}
272
273// abortDrag ends the drag where it is; the held button's release is
274// dropped.
275func (c *Core) abortDrag() {
276	if c.drag != nil {
277		c.swallow[c.drag.button] = true
278		c.drag = nil
279	}
280}
281
282// dropAt is the drop target under the global point x, y.
283func (c *Core) dropAt(id WindowID, x, y float64) dropTarget {
284	i := c.screenAt(x, y)
285	if i < 0 {
286		return dropTarget{}
287	}
288	sc := c.screens[i]
289	m := sc.mon
290	lx, ly := int(x)-sc.x, int(y)-sc.y
291	w := m.Current()
292	if m.previewing() || w.pinned() || !m.frameHas(float64(lx), float64(ly)) {
293		return dropTarget{}
294	}
295	t := w.dropIn(id, lx, ly, max(4*c.cfg.Border.Width, 4))
296	t.screen, t.ws = sc, w
297	return t
298}
299
300// visibleColumn is a column on screen and the bounds of its windows.
301type visibleColumn struct {
302	index int
303	rect  Rect
304}
305
306// dropIn resolves the drop of window id at the output-local point x, y,
307// hints t logical px thick.
308func (w *Workspace) dropIn(id WindowID, x, y, t int) dropTarget {
309	layout := w.Layout()
310	for _, p := range slices.Backward(layout) {
311		// Floats and the stash are over the tiles: nothing lands there.
312		if p.Floating && !p.Hidden && !p.Below && p.ID != id && contains(p.Rect, x, y) {
313			return dropTarget{}
314		}
315	}
316	src, srcRow := w.columnOf(id), -1
317	if src >= 0 {
318		srcRow = slices.Index(w.Columns[src].Windows, id)
319	}
320	lone := src >= 0 && len(w.Columns[src].Windows) == 1
321	var cols []visibleColumn
322	for _, p := range layout {
323		if p.Floating || p.Hidden || !p.Rect.Overlaps(w.Usable) {
324			continue
325		}
326		ci := w.columnOf(p.ID)
327		if ci < 0 {
328			continue
329		}
330		if n := len(cols); n > 0 && cols[n-1].index == ci {
331			cols[n-1].rect = union(cols[n-1].rect, p.Rect)
332		} else {
333			cols = append(cols, visibleColumn{index: ci, rect: p.Rect})
334		}
335	}
336	if len(w.Columns) == 0 || src < 0 && len(cols) == 0 {
337		if !contains(w.Usable, x, y) {
338			return dropTarget{}
339		}
340		g := w.gap()
341		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)}
342		return dropTarget{kind: dropInsert, hints: outline(r, t)}
343	}
344	if !w.policy().equalCells {
345		if tgt, ok := w.gapDrop(cols, id, src, lone, x, y, t); ok {
346			return tgt
347		}
348	}
349	for _, p := range layout {
350		if p.Floating || p.Hidden || !contains(p.Rect, x, y) {
351			continue
352		}
353		ci := w.columnOf(p.ID)
354		if ci < 0 {
355			continue
356		}
357		r := p.Rect
358		band := max(r.H/dropBandDiv, 1)
359		row := slices.Index(w.Columns[ci].Windows, p.ID)
360		switch {
361		case y < r.Y+band:
362			// Above the window just below it, or above itself: no move.
363			if p.ID == id || ci == src && row == srcRow+1 {
364				return dropTarget{}
365			}
366			return dropTarget{kind: dropAbove, anchor: p.ID, hints: []Rect{{X: r.X, Y: r.Y, W: r.W, H: min(t, r.H)}}}
367		case y >= r.Y+r.H-band:
368			if p.ID == id || ci == src && row == srcRow-1 {
369				return dropTarget{}
370			}
371			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)}}}
372		default:
373			if ci == src {
374				return dropTarget{}
375			}
376			for _, vc := range cols {
377				if vc.index == ci {
378					return dropTarget{kind: dropSwap, anchor: p.ID, hints: outline(vc.rect, t)}
379				}
380			}
381			return dropTarget{}
382		}
383	}
384	return dropTarget{}
385}
386
387// gapDrop is the insert zone of scroll overflow: the gaps between the
388// columns on screen and beside the outer ones.
389func (w *Workspace) gapDrop(cols []visibleColumn, id WindowID, src int, lone bool, x, y, t int) (dropTarget, bool) {
390	pad := max(w.gap()/2, gapPad)
391	for i := 0; i <= len(cols); i++ {
392		var mid, at int
393		var ref Rect
394		switch {
395		case i == 0:
396			ref = cols[0].rect
397			mid, at = ref.X-w.gap()/2, cols[0].index
398		case i == len(cols):
399			ref = cols[i-1].rect
400			mid, at = ref.X+ref.W+w.gap()/2, cols[i-1].index+1
401		default:
402			a, b := cols[i-1].rect, cols[i].rect
403			ref = union(a, b)
404			mid, at = (a.X+a.W+b.X)/2, cols[i].index
405		}
406		if x < mid-pad || x >= mid+pad || y < ref.Y || y >= ref.Y+ref.H {
407			continue
408		}
409		// A lone column next to its own place does not move.
410		if lone && (at == src || at == src+1) {
411			return dropTarget{}, true
412		}
413		var anchor WindowID
414		if at < len(w.Columns) {
415			anchor = w.Columns[at].Windows[0]
416			if anchor == id {
417				anchor = w.Columns[at].Windows[1]
418			}
419		}
420		hint := Rect{X: mid - t/2, Y: ref.Y, W: t, H: ref.H}
421		return dropTarget{kind: dropInsert, anchor: anchor, hints: []Rect{hint}}, true
422	}
423	return dropTarget{}, false
424}
425
426// contains reports whether r holds the point x, y.
427func contains(r Rect, x, y int) bool {
428	return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H
429}
430
431// union is the smallest rect holding a and b.
432func union(a, b Rect) Rect {
433	x, y := min(a.X, b.X), min(a.Y, b.Y)
434	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}
435}
436
437// outline is the four sides of r, t thick, inside it.
438func outline(r Rect, t int) []Rect {
439	t = min(t, r.W/2, r.H/2)
440	if t <= 0 {
441		return nil
442	}
443	return []Rect{
444		{X: r.X, Y: r.Y, W: r.W, H: t},
445		{X: r.X, Y: r.Y + r.H - t, W: r.W, H: t},
446		{X: r.X, Y: r.Y + t, W: t, H: r.H - 2*t},
447		{X: r.X + r.W - t, Y: r.Y + t, W: t, H: r.H - 2*t},
448	}
449}
450
451// drop moves tile id to target t, possibly on another workspace or
452// screen; focus follows it.
453func (c *Core) drop(id WindowID, t dropTarget) {
454	from, src := c.screenOf(id)
455	if from == nil || t.ws == nil || src.columnOf(id) < 0 {
456		return
457	}
458	w := t.ws
459	ci := src.columnOf(id)
460	lone := len(src.Columns[ci].Windows) == 1
461	if t.kind == dropSwap && w == src && lone {
462		to := w.columnOf(t.anchor)
463		// Their fields move with them, as MoveColumn.
464		w.Columns[ci], w.Columns[to] = w.Columns[to], w.Columns[ci]
465		// Band starts are positional: the flag stays at its index.
466		w.Columns[ci].BandStart, w.Columns[to].BandStart = w.Columns[to].BandStart, w.Columns[ci].BandStart
467		w.FocusID(id)
468	} else {
469		cells := 0
470		if lone {
471			cells = src.Columns[ci].Cells
472		}
473		srcIndex := ci
474		src.dropSlotOf(id)
475		src.RemoveWindow(id)
476		col := Column{Windows: []WindowID{id}, Cells: cells}
477		switch t.kind {
478		case dropAbove, dropBelow:
479			ai := w.columnOf(t.anchor)
480			if ai < 0 {
481				w.receive(col, -1)
482				break
483			}
484			tc := &w.Columns[ai]
485			row := slices.Index(tc.Windows, t.anchor)
486			if t.kind == dropBelow {
487				row++
488			}
489			if row == 0 {
490				// The slot follows the first window.
491				tc.Slot = 0
492			}
493			tc.Windows = slices.Insert(tc.Windows, row, id)
494			tc.Shares = nil
495			w.Activate(id)
496		default:
497			at, anchorCol := len(w.Columns), -1
498			if t.anchor != 0 {
499				if ai := w.columnOf(t.anchor); ai >= 0 {
500					at, anchorCol = ai, ai
501					// A stacked window dropped on a column right of its
502					// own lands after it.
503					if t.kind == dropSwap && w == src && ai >= srcIndex {
504						at = ai + 1
505					}
506				}
507			}
508			if w.full() {
509				// No room for a column: it joins the outlined one.
510				i := min(at, len(w.Columns)-1)
511				if anchorCol >= 0 {
512					i = anchorCol
513				}
514				w.Columns[i].Windows = append(w.Columns[i].Windows, id)
515				w.Columns[i].Shares = nil
516				w.Activate(id)
517			} else {
518				w.receive(col, at)
519			}
520		}
521	}
522	from.mon.normalize()
523	t.screen.mon.normalize()
524	c.focusScreen = c.screenIndex(t.screen.name())
525}
526
527// edgeScroll scrolls a scroll-overflow workspace by one column when a
528// tile drag moves the pointer against its usable edge. It reports whether
529// the view moved.
530func (c *Core) edgeScroll(t time.Duration) bool {
531	i := c.screenAt(c.cursorX, c.cursorY)
532	if i < 0 || t-c.drag.scrolled < edgeScrollInterval && c.drag.scrolled != 0 {
533		return false
534	}
535	sc := c.screens[i]
536	w := sc.mon.Current()
537	if w.policy().equalCells || len(w.Columns) == 0 || w.pinned() {
538		return false
539	}
540	lx := int(c.cursorX) - sc.x
541	dir := 0
542	switch {
543	case lx < w.Usable.X+edgeScroll:
544		dir = -1
545	case lx >= w.Usable.X+w.Usable.W-edgeScroll:
546		dir = 1
547	default:
548		return false
549	}
550	if !w.scrollBy(dir) {
551		return false
552	}
553	c.drag.scrolled = max(t, 1)
554	return true
555}
556
557// scrollBy moves the view one column toward dir, within the row; the
558// focus stays. It reports whether the view moved.
559func (w *Workspace) scrollBy(dir int) bool {
560	g := w.gap()
561	n := len(w.Columns)
562	maxView := max(w.columnX(n-1)+w.columnWidth(n-1)+g-(w.Usable.X+w.Usable.W), 0)
563	view := w.View
564	if dir < 0 {
565		// The first column not fully on screen at the left.
566		for i := n - 1; i >= 0; i-- {
567			if w.columnX(i)-view < w.Usable.X+g {
568				view = w.columnX(i) - w.Usable.X - g
569				break
570			}
571		}
572	} else {
573		for i := range n {
574			if w.columnX(i)+w.columnWidth(i)-view > w.Usable.X+w.Usable.W-g {
575				view = w.columnX(i) + w.columnWidth(i) + g - w.Usable.X - w.Usable.W
576				break
577			}
578		}
579	}
580	view = min(max(view, 0), maxView)
581	if view == w.View {
582		return false
583	}
584	w.view.stop()
585	w.View = view
586	return true
587}
588
589// makeFree turns native float f into a free float where it is now.
590func (w *Workspace) makeFree(f *Float) {
591	if f.free {
592		return
593	}
594	r := w.floatRect(*f)
595	if f.W <= 0 || f.H <= 0 {
596		f.W, f.H = max(r.W-2*w.border, 1), max(r.H-2*w.border, 1)
597	}
598	f.free = true
599	w.moveFree(f, r, 0, 0)
600}

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 23

Tags 10

↑↓ moveenter openesc close