v0.2.0 13c6a90

drag.go RawHistoryFolder

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}

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 25

Tags 10

↑↓ moveenter openesc close