v0.3.0 cf370a0

core.go RawHistoryFolder

1package core
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"slices"
8	"strconv"
9	"strings"
10	"time"
11	"unicode"
12
13	"github.com/bnema/neferwl/internal/ports"
14)
15
16var ErrQuit = errors.New("quit requested")
17
18var errSecurityChanged = errors.New("security epoch changed during input")
19
20// Channels connects the owner to adapters. Scenes must have capacity 1: core is
21// its only sender and may drain a stale set; consumers only receive. Each
22// send holds one scene per output.
23type Channels struct {
24	// Security is the defensive session gate; nil preserves standalone operation.
25	Security ports.SessionSecurity
26	Client   <-chan ports.ClientEvent
27	Input    <-chan ports.InputEvent
28	Output   <-chan ports.OutputEvent
29	Config   <-chan ports.ConfigChanged
30	Commands chan<- ports.ClientCommand
31	Spawn    chan<- ports.SpawnRequest
32	Scenes   chan []ports.Scene
33	// Layouts, when set, receives the output layout whenever it changes,
34	// latest first (capacity 1, drained like Scenes). Input and cursors use it.
35	Layouts chan ports.Layout
36	// Constraints, when set, receives the active pointer constraint in
37	// global logical coordinates whenever it changes (capacity 1, drained
38	// like Scenes). Input applies it to the pointer.
39	Constraints  chan ports.PointerConstraint
40	ConfigErrors chan<- error
41	// State, when set, receives a snapshot for scripts whenever it changes,
42	// latest first (capacity 1, drained like Scenes).
43	State      chan ports.State
44	Workspaces chan ports.Workspaces
45	// Scales, when set, receives each scale a scale bind sets, to persist it.
46	// Core does not wait: a full channel drops the change.
47	Scales chan<- ports.ScaleChanged
48	// Terminal enables automatic terminal opening according to terminal.auto-open.
49	Terminal bool
50	// Clock tells the time for fullscreenGrace and slides; nil is the
51	// system clock.
52	Clock ports.Clock
53	// Frames, when set, reports outputs' page flips: a running slide moves
54	// one step per flip. Without flips it moves on a timer.
55	Frames <-chan ports.OutputFrame
56}
57
58// fullscreenGrace is how long after mapping a window's fullscreen request
59// is ignored. Wine asks for fullscreen whenever a window has the monitor's
60// size, so a launcher that remembered a fullscreen size would open
61// fullscreen and hide the windows it opens next. The user can still
62// fullscreen it (bind or taskbar), and an app can ask again later.
63const fullscreenGrace = time.Second
64
65type binding struct {
66	mods ports.Mods
67	key  string
68}
69type Core struct {
70	security        ports.SecurityState
71	lockSurfaces    []ports.LockSurfacePlacement
72	lockFocus       WindowID
73	lockPinned      bool // lockFocus was clicked or typed into: it keeps focus
74	inputKeys       map[string]bool
75	inputActive     bool
76	startup         [][]string
77	nextWorkspaceID uint64
78	ch              Channels
79	cfg             ports.Config
80	screens         []*screen
81	focusScreen     int
82	// offGone names outputs unplugged while turned off: a display that
83	// reconnects from deep sleep comes back off, until the next input.
84	offGone    map[string]bool
85	binds      map[binding]Action
86	pressed    map[string]bool
87	configures configures
88	pointer    WindowID
89	grab       WindowID
90	buttons    map[uint32]bool
91	// drag is the pointer drag in progress (drag.go); swallow holds the
92	// buttons whose release no client must see. mods are the modifiers
93	// held, cmdMod the one keyboard.cmd names. lastButton is the latest
94	// press a client saw, the one xdg_toplevel.move's serial names.
95	drag             *dragState
96	swallow          map[uint32]bool
97	lastButton       uint32
98	mods             ports.Mods
99	cmdMod           ports.Mods
100	cursorX, cursorY float64 // global, logical
101	pointerOutput    string  // output under the pointer at the last motion
102	// constrained is the constraint wayland activated; constraint is it
103	// resolved to global coordinates, as last sent to input.
104	constrained ports.PointerConstrained
105	constraint  ports.PointerConstraint
106	seq         uint64
107	// layerChanged is set once layer state arrives from wayland.
108	layerChanged bool
109	sentOutputs  ports.SetOutputs
110	specs        []NamedWorkspace
111	presets      []Width
112	slots        map[slotKey]*slotState
113	placement    spawnPlacement
114	// toSpawn holds slots to start; Run sends them (apply has no context).
115	toSpawn               []slotKey
116	sentPending           bool
117	firstTerminalResolved bool
118	windows               windowRegistry
119	sentState             ports.State
120	sentWorkspaces        ports.Workspaces
121	// popups are the placed xdg_popups; popupOrder stacks them, oldest first.
122	popups     map[WindowID]*popupState
123	popupOrder []WindowID
124	keyboard   keyboard
125	// activity is when core last told wayland about user input
126	// (ports.UserActivity), sent at most once per ActivityInterval.
127	activity time.Time
128	// swipe is the touchpad swipe in progress. frameC fires when no page
129	// flip came in time to move a running slide (frameStop stops it).
130	swipe     *swipeGesture
131	frameC    <-chan time.Time
132	frameStop func() bool
133	// motionTime is the time of the last pointer motion; pointerAt is the
134	// last position sent in the pointer's window.
135	motionTime time.Duration
136	pointerAt  [2]float64
137	// capt is the capture state (capture.go, capindicator.go).
138	capt captureState
139	// pulse marks a window that just got the focus (pulse.go).
140	pulse focusPulse
141}
142
143func keyName(s string) string {
144	if len(s) == 1 && unicode.IsLetter(rune(s[0])) {
145		return strings.ToLower(s)
146	}
147	return s
148}
149func parseBind(s, cmd string) (binding, error) {
150	parts := strings.Split(s, "+")
151	b := binding{key: keyName(parts[len(parts)-1])}
152	if b.key == "" {
153		return b, fmt.Errorf("invalid bind %q", s)
154	}
155	for _, p := range parts[:len(parts)-1] {
156		var m ports.Mods
157		switch p {
158		case "Cmd":
159			switch cmd {
160			case "super":
161				m = ports.ModSuper
162			case "alt":
163				m = ports.ModAlt
164			case "ctrl":
165				m = ports.ModCtrl
166			}
167		case "Shift":
168			m = ports.ModShift
169		case "Ctrl":
170			m = ports.ModCtrl
171		case "Alt":
172			m = ports.ModAlt
173		case "Super":
174			m = ports.ModSuper
175		}
176		if m == 0 || b.mods&m != 0 {
177			return b, fmt.Errorf("invalid bind %q", s)
178		}
179		b.mods |= m
180	}
181	return b, nil
182}
183func (c *Core) apply(cfg ports.Config) error {
184	if cfg.Layout.MaxColumns < 1 {
185		return fmt.Errorf("invalid max columns")
186	}
187	presets := make([]Width, 0, len(cfg.Layout.Presets))
188	for _, s := range cfg.Layout.Presets {
189		v, e := ParseWidth(s)
190		if e != nil {
191			return e
192		}
193		presets = append(presets, v)
194	}
195	if cfg.Layout.Gaps < 0 {
196		return fmt.Errorf("invalid gaps")
197	}
198	if cfg.Keyboard.CmdKey != "super" && cfg.Keyboard.CmdKey != "alt" && cfg.Keyboard.CmdKey != "ctrl" {
199		return fmt.Errorf("invalid cmd key")
200	}
201	binds := map[binding]Action{}
202	for combo, a := range cfg.Binds {
203		b, e := parseBind(combo, cfg.Keyboard.CmdKey)
204		if e != nil {
205			return e
206		}
207		if _, ok := binds[b]; ok {
208			return fmt.Errorf("duplicate bind %q", combo)
209		}
210		if _, ok := SpawnArgv(Action(a)); ok {
211			binds[b] = Action(a)
212			continue
213		}
214		if Action(a) == ActionScaleUp || Action(a) == ActionScaleDown {
215			binds[b] = Action(a)
216			continue
217		}
218		if _, _, ok := WorkspaceArg(Action(a)); ok {
219			binds[b] = Action(a)
220			continue
221		}
222		if _, _, ok := ResizeArg(Action(a)); ok {
223			binds[b] = Action(a)
224			continue
225		}
226		if _, ok := NamedArg(Action(a)); ok {
227			binds[b] = Action(a)
228			continue
229		}
230		switch Action(a) {
231		case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight,
232			ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspaceUp, ActionMoveWorkspaceDown, ActionToggleFloating:
233		default:
234			return fmt.Errorf("invalid action %q", a)
235		}
236		binds[b] = Action(a)
237	}
238	overflow := func(v string) (Overflow, error) {
239		switch o := Overflow(v); o {
240		case "", OverflowScroll, OverflowFixed:
241			return o, nil
242		}
243		return "", fmt.Errorf("invalid overflow %q", v)
244	}
245	if _, err := overflow(cfg.Layout.Overflow); err != nil {
246		return err
247	}
248	if cfg.Layout.Overflow == "" {
249		cfg.Layout.Overflow = string(OverflowScroll)
250	}
251	for _, o := range cfg.Layout.Outputs {
252		if _, err := overflow(o.Overflow); err != nil {
253			return err
254		}
255		if o.Output == "" || o.MaxColumns < 0 {
256			return fmt.Errorf("invalid layout for output %q", o.Output)
257		}
258	}
259	named := make([]NamedWorkspace, 0, len(cfg.Workspaces))
260	seen := map[string]bool{}
261	for _, ws := range cfg.Workspaces {
262		o, err := overflow(ws.Overflow)
263		if err != nil {
264			return err
265		}
266		if ws.Name == "" || seen[ws.Name] || ws.MaxColumns < 0 || ws.Size[0] < 0 || ws.Size[1] < 0 {
267			return fmt.Errorf("invalid workspace %q", ws.Name)
268		}
269		seen[ws.Name] = true
270		named = append(named, NamedWorkspace{Name: ws.Name, Monitor: ws.Monitor, MaxColumns: ws.MaxColumns, Overflow: o, Size: ws.Size})
271	}
272	specs, err := parseSlots(cfg.Workspaces)
273	if err != nil {
274		return err
275	}
276	c.cfg = cfg
277	c.binds = binds
278	c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]
279	c.specs, c.presets = named, presets
280	c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
281	c.named()
282	for _, s := range c.screens {
283		c.settings(s.mon)
284	}
285	c.applyOutputConfig()
286	return nil
287}
288
289// publishPending tells wayland at once when slots start waiting, before
290// their windows can map.
291func (c *Core) publishPending(ctx context.Context) error {
292	if p := c.placement.anyPending(); p != c.sentPending {
293		if err := c.command(ctx, ports.SlotsPending{Pending: p}); err != nil {
294			return err
295		}
296		c.sentPending = p
297	}
298	return nil
299}
300
301// spawnSlots starts the slots queued by config. With shown set (the user
302// just switched to another workspace), it also refills the empty slots of
303// the workspace now on screen.
304func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
305	if c.protectionRequested() {
306		return nil
307	}
308	keys := c.toSpawn
309	c.toSpawn = nil
310	if shown {
311		keys = append(keys, c.refill()...)
312	}
313	started := map[slotKey]bool{}
314	for i, key := range keys {
315		if c.protectionRequested() {
316			c.toSpawn = append(c.toSpawn, keys[i:]...)
317			return nil
318		}
319		st, ok := c.slots[key]
320		// A key can be queued twice (new slot on the workspace on screen).
321		if !ok || started[key] || st.window != 0 {
322			continue
323		}
324		started[key] = true
325		req := c.spawnSlot(key)
326		// Wayland must read slot tokens before this window can map.
327		pendingErr := c.publishPending(ctx)
328		if pendingErr != nil || c.protectionRequested() {
329			c.placement.dropSlot(key)
330			st.stale = false
331			c.toSpawn = append(c.toSpawn, keys[i:]...)
332			return pendingErr
333		}
334		if !c.trySpawn(ctx, req) {
335			c.placement.dropSlot(key)
336			st.stale = false
337			c.toSpawn = append(c.toSpawn, keys[i:]...)
338			return ctx.Err()
339		}
340	}
341	return nil
342}
343func New(cfg ports.Config, ch Channels) (*Core, error) {
344	if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) || (ch.Constraints != nil && cap(ch.Constraints) != 1) || (ch.State != nil && cap(ch.State) != 1) || (ch.Workspaces != nil && cap(ch.Workspaces) != 1) {
345		return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
346	}
347	// A placeholder screen holds windows until the first output arrives.
348	c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures()}
349	c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
350	if err := c.apply(cfg); err != nil {
351		return nil, err
352	}
353	return c, nil
354}
355
356// newTimer makes a timer on clock, or the system timer when clock is nil.
357func newTimer(clock ports.Clock, d time.Duration) (<-chan time.Time, func() bool) {
358	if clock != nil {
359		t := clock.NewTimer(d)
360		return t.C(), t.Stop
361	}
362	t := time.NewTimer(d)
363	return t.C, t.Stop
364}
365
366func (c *Core) now() time.Time {
367	if c.ch.Clock != nil {
368		return c.ch.Clock.Now()
369	}
370	return time.Now()
371}
372
373func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
374	if c.inputEpochChanged() {
375		return errSecurityChanged
376	}
377	if c.ch.Security != nil {
378		v = ports.SecurityCommand{State: c.security, Command: v}
379	}
380	select {
381	case <-ctx.Done():
382		return ctx.Err()
383	case c.ch.Commands <- v:
384		if c.inputEpochChanged() {
385			return errSecurityChanged
386		}
387		return nil
388	}
389}
390
391// clientRect is the placement minus the border on its inset sides.
392// The renderer applies the same inset (ports.Border).
393func (c *Core) clientRect(p Placement) Rect {
394	if p.Fullscreen {
395		return p.Rect
396	}
397	return p.Rect.Inset(p.Inset, c.cfg.Border.Width)
398}
399
400// onScreen reports whether a placement is drawn on its output o: not
401// hidden and not scrolled off.
402func onScreen(p Placement, o Rect) bool {
403	return !p.Hidden && p.Rect.Overlaps(o)
404}
405
406// floatDim is the veil opacity of a layout: dim only when a float is
407// drawn above the tiles, never for a demoted covering float alone nor for
408// an overview preview.
409func floatDim(layout []Placement, o Rect, dim float64) float64 {
410	shown := false
411	for _, p := range layout {
412		if p.Floating && !p.Below && p.Preview == 0 && onScreen(p, o) {
413			if p.Fullscreen {
414				return 0
415			}
416			shown = true
417		}
418	}
419	if !shown {
420		return 0
421	}
422	return dim
423}
424
425// visible reports whether the window, layer surface or popup is on screen
426// on any output.
427func (c *Core) visible(id WindowID) bool {
428	_, _, ok := c.windowRect(id)
429	return ok
430}
431
432// windowFocus is the focused window of each output.
433func (c *Core) windowFocus() map[*screen]WindowID {
434	m := make(map[*screen]WindowID, len(c.screens))
435	for _, sc := range c.screens {
436		m[sc], _ = sc.mon.Focused()
437	}
438	return m
439}
440
441// keyboardFocus is the surface holding the keyboard (see keyboard.focus).
442func (c *Core) keyboardFocus() WindowID {
443	if c.security.Protected {
444		return c.lockKeyboardFocus()
445	}
446	var exclusive WindowID
447	for _, sc := range c.screens {
448		for _, l := range sc.layers {
449			if l.Keyboard == 1 && (l.Layer == ports.LayerTop || l.Layer == ports.LayerOverlay) && l.ID > exclusive {
450				exclusive = l.ID
451			}
452		}
453	}
454	window, _ := c.cur().mon.Focused()
455	return c.keyboard.focus(keyboardCandidates{
456		exclusive: exclusive,
457		grab:      c.grabFocus(),
458		window:    window,
459		layerShown: func(id WindowID) bool {
460			return c.onDemand(id) && c.visible(id)
461		},
462		windows: c.windowFocus,
463	})
464}
465
466// setLayers splits the mapped layer surfaces by output. A surface without an
467// output, or on an unknown one, goes to the focused output.
468func (c *Core) setLayers(all []ports.LayerSurface) {
469	previous := make(map[WindowID]bool)
470	for _, sc := range c.screens {
471		for _, l := range sc.layers {
472			previous[l.ID] = true
473		}
474		sc.layers = nil
475	}
476	for _, l := range all {
477		delete(previous, l.ID)
478		sc := c.cur()
479		if i := c.screenIndex(l.Output); i >= 0 {
480			sc = c.screens[i]
481		}
482		sc.layers = append(sc.layers, l)
483	}
484	for id := range previous {
485		c.windows.layerGone(id)
486	}
487	c.pruneRetained()
488	c.clampCaptureKeyboard()
489	for _, sc := range c.screens {
490		sc.arrange()
491	}
492}
493
494func (c *Core) publish(ctx context.Context) error {
495	if c.inputEpochChanged() {
496		return errSecurityChanged
497	}
498	c.syncSecurity()
499	if c.security.Protected {
500		return c.publishProtected(ctx)
501	}
502	for _, sc := range c.screens {
503		// The overview's selection is provisional: it focuses nothing yet.
504		if !sc.mon.ov.open {
505			sc.mon.Current().settleLeft()
506		}
507	}
508	c.captureExpire()
509	capture, err := c.captureEvaluate(ctx)
510	if err != nil {
511		return err
512	}
513	// The workspace a session renders off screen keeps its real layout for
514	// the configures, popups and CaptureScene of this publish.
515	c.captureTrack(capture)
516	if err := c.closeHiddenPopups(ctx); err != nil {
517		return err
518	}
519	// Clients learn outputs and scales before the configures sized for them.
520	if err := c.syncOutputs(ctx); err != nil {
521		return err
522	}
523	// Output layout must be known before an automatic terminal can map.
524	if err := c.spawnEmpty(ctx); err != nil {
525		return err
526	}
527	if err := c.publishPending(ctx); err != nil {
528		return err
529	}
530	focus := c.keyboardFocus()
531	window, _ := c.cur().mon.Focused()
532	c.pulseFocus(window, focus == window)
533	var pulse float64
534	if c.pulse.id != 0 {
535		pulse = c.advancePulse(c.now())
536	}
537	// drawable: the scene can show a pulse on the focused window.
538	drawable := false
539	scenes := make([]ports.Scene, 0, len(c.screens))
540	// Before the first output (and after the last is unplugged) the
541	// placeholder's scene has no output name: no renderer draws it.
542	for i, sc := range c.screens {
543		c.seq++
544		o := sc.mon.Output()
545		// frame is the viewport of the workspace on screen: the whole output
546		// unless it has a size override (never in the overview).
547		frame := sc.mon.Frame()
548		var clip Rect
549		if frame != (Rect{W: o.W, H: o.H}) {
550			clip = frame
551		}
552		scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}
553		layout := sc.mon.Layout()
554		var real map[WindowID]Placement
555		if sc.mon.ov.open {
556			real = make(map[WindowID]Placement)
557			for w := range sc.mon.all() {
558				for _, p := range w.Layout() {
559					real[p.ID] = p
560				}
561			}
562		}
563		scene.Dim = floatDim(layout, frame, c.cfg.Floating.Dim)
564		if sc.mon.ov.open {
565			// Darken the wallpaper around the previews.
566			scene.Dim = c.cfg.Floating.Dim
567			scene.DimBehind = scene.Dim > 0
568		}
569		if d := c.drag; d != nil && d.target.screen == sc && d.target.kind != dropNone {
570			scene.DropHints = slices.Clone(d.target.hints)
571		}
572		// Only the focused output lights the focused window's lines.
573		scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
574		if sc.mon.ov.open {
575			// Frame the selection and separate numbered and named row groups.
576			_, scene.Separators = sc.mon.overviewRows()
577			scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)
578		}
579		for _, p := range layout {
580			// Only the focused output has an activated window.
581			focused := p.Focused && i == c.focusScreen
582			sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview}
583			if p.Peek {
584				sw.Dim = c.cfg.Stash.Dim
585			}
586			if focused && p.ID == c.pulse.target && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {
587				drawable = true
588				if p.ID == c.pulse.id {
589					sw.FocusEffect = pulse
590				}
591			}
592			scene.Windows = append(scene.Windows, sw)
593			t := configureTarget{output: sc.name(), area: frame, focused: focused, captured: capture != nil && capture.window == p.ID}
594			if !p.Hidden && p.Preview == 0 {
595				// Only a sized configure needs the client size.
596				t.client, t.imposed = c.clientRect(p), sc.mon.Current().imposedFloat(p.ID)
597			} else if t.captured && p.Hidden {
598				// A captured hidden window is sized like its capture.
599				t.client = capture.windowSz
600			} else if p.Preview > 0 && !p.Hidden {
601				if rp, ok := real[p.ID]; ok && !rp.Hidden {
602					t.realTiled = !rp.Floating
603					t.client = c.clientRect(rp)
604				}
605			}
606			cp, t := c.captureConfigure(sc, p, t)
607			if v, send := c.configures.nextWithCapture(p, t, cp); send {
608				if err := c.command(ctx, v); err != nil {
609					return err
610				}
611				c.configures.mark(v)
612			}
613		}
614		scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
615		scene.CaptureIndicators = c.captureIndicators(sc)
616		if scene.Capture = c.captureSceneFor(sc, capture); scene.Capture != nil {
617			switch sc {
618			case capture.hiddenScr:
619				scene.CaptureScene = c.captureScene(scene.Seq)
620			case capture.windowScr:
621				scene.CaptureScene = c.captureWindowScene(capture, scene.Seq)
622			}
623		}
624		scenes = append(scenes, scene)
625	}
626	c.configures.prune()
627	// A pulse nothing shows (fullscreen, overview) asks for no frames.
628	if c.pulse.drawable = drawable; !drawable {
629		c.pulse.id = 0
630	}
631	// A workspace switch can hide the window under the pointer; it must not get
632	// clicks. The next motion re-runs hit-testing.
633	if c.pointer != 0 && !c.visible(c.pointer) {
634		c.pointer = 0
635		if c.grab == 0 {
636			if err := c.command(ctx, ports.PointerFocus{}); err != nil {
637				return err
638			}
639		}
640	}
641	if err := c.syncFocus(ctx, focus); err != nil {
642		return err
643	}
644	if err := c.updateInhibit(ctx); err != nil {
645		return err
646	}
647	c.resolveConstraint()
648	if c.inputEpochChanged() {
649		return errSecurityChanged
650	}
651	if c.syncSecurity() {
652		return c.publish(ctx)
653	}
654	latest(c.ch.Scenes, scenes)
655	c.publishState()
656	c.publishWorkspaces()
657	c.armCaptureTimer()
658	// A running slide moves on the next flip, or on the fallback timer.
659	if c.animating() {
660		if c.frameC == nil {
661			c.armFrame()
662		}
663	} else {
664		c.stopFrame()
665	}
666	return nil
667}
668
669// step moves the running slides to now and publishes the frame.
670func (c *Core) step(ctx context.Context) error {
671	c.stopFrame()
672	c.animate(c.now())
673	return c.slid(ctx, false)
674}
675
676// slid publishes a moved view; shown reports another workspace came on
677// screen.
678func (c *Core) slid(ctx context.Context, shown bool) error {
679	if err := c.workspaceVisible(ctx, shown); err != nil {
680		return err
681	}
682	if err := c.publish(ctx); err != nil {
683		return err
684	}
685	return c.rehit(ctx)
686}
687
688// rehit points the pointer at what now lies under a still cursor, as
689// windows slide under it. A held button keeps its grab.
690func (c *Core) rehit(ctx context.Context) error {
691	if c.grab != 0 || c.drag != nil {
692		return nil
693	}
694	id, x, y := c.hit(c.cursorX, c.cursorY)
695	if id != c.pointer {
696		c.pointer, c.pointerAt = id, [2]float64{x, y}
697		return c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y})
698	}
699	if id == 0 || c.pointerAt == [2]float64{x, y} {
700		return nil
701	}
702	c.pointerAt = [2]float64{x, y}
703	// The same window moved under the cursor: it moves in the window. No
704	// relative motion: the device did not move. The input clock is the
705	// device's; the last motion's time is the closest core knows.
706	return c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, Time: c.motionTime})
707}
708
709// updateInhibit makes the shortcuts inhibitor of the keyboard focus the
710// active one and tells wayland when it changes.
711func (c *Core) updateInhibit(ctx context.Context) error {
712	if c.security.Protected {
713		return nil
714	}
715	f := c.keyboardFocus()
716	release, activate, changed := c.keyboard.inhibit(f, c.windows.lookup(f).inhibitShortcuts)
717	if !changed {
718		return nil
719	}
720	if release != 0 {
721		if err := c.command(ctx, ports.ShortcutsInhibitState{Window: release}); err != nil {
722			return err
723		}
724	}
725	if activate != 0 {
726		return c.command(ctx, ports.ShortcutsInhibitState{Window: activate, Active: true})
727	}
728	return nil
729}
730
731// resolveConstraint turns the active constraint into global coordinates
732// and sends it to input when it changed. A hidden window holds nothing.
733func (c *Core) resolveConstraint() {
734	var g ports.PointerConstraint
735	if full, vis, ok := c.shownClient(c.constrained.ID); ok {
736		r := full
737		if w := c.constrained.Rect; w.W > 0 && w.H > 0 {
738			// The region is window-local: placed from the full client origin,
739			// clipped to the window, then to what the viewport shows.
740			x0, y0 := max(full.X, full.X+w.X), max(full.Y, full.Y+w.Y)
741			x1, y1 := min(full.X+full.W, full.X+w.X+w.W), min(full.Y+full.H, full.Y+w.Y+w.H)
742			if x1 > x0 && y1 > y0 {
743				r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
744			}
745		}
746		r = intersect(r, vis)
747		if r.W > 0 && r.H > 0 {
748			g = ports.PointerConstraint{Mode: c.constrained.Mode, Rect: r}
749		} else {
750			// The region lies outside the viewport: hold the visible client.
751			g = ports.PointerConstraint{Mode: c.constrained.Mode, Rect: vis}
752		}
753	}
754	// The cursor follows core; input resyncs to it only on a change.
755	if g.Mode == c.constraint.Mode && g.Rect == c.constraint.Rect {
756		return
757	}
758	// A layout change must not leave a locked cursor off its window.
759	c.cursorX, c.cursorY = g.Clamp(c.cursorX, c.cursorY)
760	g.X, g.Y = c.cursorX, c.cursorY
761	c.constraint = g
762	if c.ch.Constraints != nil {
763		latest(c.ch.Constraints, g)
764	}
765}
766
767// intersect is the overlap of a and b, empty (zero size) when disjoint.
768func intersect(a, b Rect) Rect {
769	x0, y0 := max(a.X, b.X), max(a.Y, b.Y)
770	x1, y1 := min(a.X+a.W, b.X+b.W), min(a.Y+a.H, b.Y+b.H)
771	if x1 <= x0 || y1 <= y0 {
772		return Rect{}
773	}
774	return Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
775}
776
777// shownClient is the global logical client rectangle of a window drawn on
778// its output, whole (window-local coordinates start at its origin), and the
779// part of it inside the workspace viewport, which alone takes the pointer.
780// ok is false when it is hidden, a preview or entirely outside the viewport.
781func (c *Core) shownClient(id WindowID) (full, visible Rect, ok bool) {
782	if id == 0 {
783		return Rect{}, Rect{}, false
784	}
785	s, _ := c.screenOf(id)
786	if s == nil {
787		return Rect{}, Rect{}, false
788	}
789	for _, p := range s.mon.Layout() {
790		if p.ID != id || p.Hidden || p.Preview > 0 {
791			continue
792		}
793		r := c.clientRect(p)
794		full = Rect{X: r.X + s.x, Y: r.Y + s.y, W: r.W, H: r.H}
795		f := s.mon.Frame()
796		visible = intersect(r, f)
797		visible.X, visible.Y = visible.X+s.x, visible.Y+s.y
798		if visible.W <= 0 || visible.H <= 0 {
799			return Rect{}, Rect{}, false
800		}
801		return full, visible, true
802	}
803	return Rect{}, Rect{}, false
804}
805
806// shownClientRect is the visible part of shownClient.
807func (c *Core) shownClientRect(id WindowID) (Rect, bool) {
808	_, vis, ok := c.shownClient(id)
809	return vis, ok
810}
811
812// warpPointer moves the cursor to a window-local point of the window under
813// it (wp_pointer_warp_v1). Input takes the new position, and the window
814// gets the motion; the active constraint still bounds the point.
815func (c *Core) warpPointer(ctx context.Context, v ports.PointerWarp) error {
816	if v.ID == 0 || v.ID != c.pointer || c.grab != 0 && c.grab != v.ID {
817		return nil
818	}
819	full, vis, ok := c.shownClient(v.ID)
820	if !ok || v.X < 0 || v.Y < 0 || v.X >= float64(full.W) || v.Y >= float64(full.H) {
821		return nil
822	}
823	// Local to the whole client, and refused outside the viewport.
824	gx, gy := float64(full.X)+v.X, float64(full.Y)+v.Y
825	if gx < float64(vis.X) || gy < float64(vis.Y) || gx >= float64(vis.X+vis.W) || gy >= float64(vis.Y+vis.H) {
826		return nil
827	}
828	c.cursorX, c.cursorY = c.constraint.Clamp(gx, gy)
829	if c.ch.Constraints != nil {
830		g := c.constraint
831		g.X, g.Y, g.Warp = c.cursorX, c.cursorY, true
832		latest(c.ch.Constraints, g)
833	}
834	// A locked pointer moved: the client knows where it put it.
835	if c.constraint.Mode == ports.ConstraintLock {
836		return nil
837	}
838	return c.rehit(ctx)
839}
840
841// syncOutputs sends the output layout when it changed; input learns the
842// geometry only when the outputs themselves moved.
843func (c *Core) syncOutputs(ctx context.Context) error {
844	v := ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}
845	if sameOutputs(v, c.sentOutputs) {
846		return nil
847	}
848	if err := c.command(ctx, v); err != nil {
849		return err
850	}
851	if c.ch.Layouts != nil && !slices.Equal(v.Outputs, c.sentOutputs.Outputs) {
852		latest(c.ch.Layouts, v.Outputs)
853	}
854	c.sentOutputs = v
855	return nil
856}
857
858// syncFocus tells wayland the keyboard focus when it changed.
859func (c *Core) syncFocus(ctx context.Context, focus WindowID) error {
860	if focus == c.keyboard.sent {
861		return nil
862	}
863	if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil {
864		return err
865	}
866	c.keyboard.sent = focus
867	return nil
868}
869
870// latest replaces any unread value: only the owner sends and drains;
871// consumers only receive.
872func latest[T any](ch chan T, v T) {
873	select {
874	case ch <- v:
875	default:
876		select {
877		case <-ch:
878		default:
879		}
880		select {
881		case ch <- v:
882		default:
883		}
884	}
885}
886
887func sameOutputs(a, b ports.SetOutputs) bool {
888	return a.Focused == b.Focused && slices.Equal(a.Outputs, b.Outputs) && slices.Equal(a.Off, b.Off)
889}
890
891// allLayers lists the layer surfaces of every output.
892func (c *Core) allLayers() []ports.LayerSurface {
893	var all []ports.LayerSurface
894	for _, sc := range c.screens {
895		all = append(all, sc.layers...)
896	}
897	return all
898}
899
900func (c *Core) acceptsInput(id WindowID, x, y float64) bool {
901	return c.windows.acceptsInput(id, x, y)
902}
903
904// hit returns the surface under the global logical point and the point in
905// its surface coordinates: popups, then overlay and top layers, then
906// windows (last visible placement is topmost), then bottom and
907// background layers.
908func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
909	if c.security.Protected {
910		return c.lockHit(x, y)
911	}
912	i := c.screenAt(x, y)
913	if i < 0 {
914		return 0, 0, 0
915	}
916	sc := c.screens[i]
917	lx, ly := x-float64(sc.x), y-float64(sc.y)
918	// Layer popups are over everything; window popups are over the windows
919	// only, under the top and overlay layers (as drawn).
920	if id, px, py := c.popupAt(sc, lx, ly, true); id != 0 {
921		return id, px, py
922	}
923	if id, px, py := c.layerAt(sc, lx, ly, true); id != 0 {
924		return id, px, py
925	}
926	if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 {
927		return id, px, py
928	}
929	if sc.mon.ov.open {
930		// Previews and the layers under them take no input: a click
931		// picks a preview (overviewClick).
932		return 0, 0, 0
933	}
934	if !sc.mon.frameHas(lx, ly) {
935		return c.layerAt(sc, lx, ly, false)
936	}
937	var id WindowID
938	var sx, sy float64
939	for _, p := range sc.mon.Layout() {
940		r := c.clientRect(p)
941		// A peek is clickable wherever it shows, border included: it may
942		// be narrower than its border. The point is clamped to its client.
943		box, lx, ly := r, lx, ly
944		if p.Peek {
945			box = p.Rect
946		}
947		if !p.Hidden && r.W > 0 && r.H > 0 && lx >= float64(box.X) && lx < float64(box.X+box.W) && ly >= float64(box.Y) && ly < float64(box.Y+box.H) {
948			if p.Peek {
949				lx, ly = min(max(lx, float64(r.X)), float64(r.X+r.W-1)), min(max(ly, float64(r.Y)), float64(r.Y+r.H-1))
950			}
951			if c.acceptsInput(p.ID, lx-float64(r.X), ly-float64(r.Y)) {
952				// Layout is bottom to top, including fullscreen and floats.
953				id, sx, sy = p.ID, lx-float64(r.X), ly-float64(r.Y)
954			}
955		}
956	}
957	if id == 0 {
958		return c.layerAt(sc, lx, ly, false)
959	}
960	return id, sx, sy
961}
962
963func (c *Core) Run(ctx context.Context) error {
964	c.syncSecurity()
965	defer c.stopFrame()
966	defer c.stopCaptureTimer()
967	defer c.stopPulseTimer()
968	// Startup commands run once per session. Full launcher queues retain a
969	// bounded remainder, selectable alongside owner events without polling.
970	c.startup = make([][]string, len(c.cfg.Startup))
971	for i, argv := range c.cfg.Startup {
972		c.startup[i] = slices.Clone(argv)
973	}
974	c.spawnStartup(ctx)
975	if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil {
976		return nil
977	}
978	c.publishState()
979	c.publishWorkspaces()
980	for {
981		if c.securityCheckpoint(ctx) != nil {
982			return nil
983		}
984		startupC, startupRequest := c.pendingStartup()
985		select {
986		case <-ctx.Done():
987			return nil
988		case startupC <- startupRequest:
989			// Successful transfer consumes startup once. Only the receiver can
990			// decide execution admission; a later gate read cannot infer it.
991			c.startup = c.startup[1:]
992			continue
993		case ev, ok := <-c.ch.Client:
994			if !ok {
995				c.ch.Client = nil
996				continue
997			}
998			if c.securityCheckpoint(ctx) != nil {
999				return nil
1000			}
1001			if c.blockProtected(ev) {
1002				continue
1003			}
1004			switch v := ev.(type) {
1005			case ports.SessionLockChanged:
1006				if !c.applyLockChanged(v) {
1007					continue
1008				}
1009			case ports.CaptureSessionOpen:
1010				c.captureOpen(v)
1011			case ports.CaptureSessionClose:
1012				c.captureClose(v.ID)
1013			case ports.CaptureFrameTaken:
1014				// Nothing new to show (the target flashes already): no scene.
1015				if !c.captureFrame(v) {
1016					continue
1017				}
1018			case ports.CaptureExclusionBegin:
1019				c.captureExclusionBegin(v)
1020			case ports.CaptureExclusionLayer:
1021				c.captureExclusionLayer(v)
1022			case ports.CaptureExclusionEnd:
1023				c.captureExclusionEnd(v.Session)
1024			case ports.LayerChanged:
1025				c.layerChanged = true
1026				c.setLayers(v.Layers)
1027			case ports.InputRegionChanged:
1028				c.windows.setRegion(v)
1029			case ports.WindowMapped:
1030				c.windows.mapped(v, c.now())
1031				c.placement.place(c, v)
1032			case ports.WindowResized:
1033				if _, w := c.screenOf(v.ID); w != nil {
1034					w.ResizeFloating(v.ID, v.Width, v.Height)
1035				}
1036			case ports.PopupRequest:
1037				if err := c.placePopup(ctx, v); err != nil {
1038					return nil
1039				}
1040			case ports.PopupMapped:
1041				if p := c.popups[v.ID]; p != nil {
1042					p.mapped = true
1043				}
1044			case ports.ShortcutsInhibit:
1045				c.windows.setInhibitShortcuts(v.Window, v.Active)
1046				if err := c.updateInhibit(ctx); err != nil {
1047					return nil
1048				}
1049				continue
1050			case ports.OutputPower:
1051				if i := c.screenIndex(v.Output); i >= 0 && c.screens[i].off == v.On {
1052					c.screens[i].off = !v.On
1053				}
1054			case ports.IdleInhibit:
1055				c.windows.setIdleInhibit(v.Window, v.Active)
1056				c.publishState()
1057				continue
1058			case ports.WindowAppID:
1059				c.windows.setAppID(v.ID, v.AppID)
1060			case ports.WindowParent:
1061				if _, w := c.screenOf(v.ID); w != nil {
1062					w.SetDialogParent(v.ID, v.Parent)
1063				}
1064			case ports.WindowUnmapped:
1065				if c.popups[v.ID] != nil {
1066					if err := c.dropPopup(ctx, v.ID); err != nil {
1067						return nil
1068					}
1069				}
1070				if err := c.closePopupsOf(ctx, v.ID); err != nil {
1071					return nil
1072				}
1073				c.windows.drop(v.ID)
1074				if c.drag != nil && c.drag.id == v.ID {
1075					c.abortDrag()
1076				}
1077				if s, _ := c.screenOf(v.ID); s != nil {
1078					s.mon.RemoveWindow(v.ID)
1079				}
1080				c.releaseSlots()
1081				if c.pointer == v.ID {
1082					c.pointer = 0
1083					if err := c.command(ctx, ports.PointerFocus{}); err != nil {
1084						return nil
1085					}
1086				}
1087			case ports.PointerConstrained:
1088				c.constrained = v
1089			case ports.PointerWarp:
1090				if c.warpPointer(ctx, v) != nil {
1091					return nil
1092				}
1093			case ports.WindowMoveRequest:
1094				if c.clientDrag(ctx, v) != nil {
1095					return nil
1096				}
1097			case ports.WindowFullscreenRequest:
1098				c.configures.answer[v.ID] = true
1099				if v.Fullscreen && !v.External && c.now().Sub(c.windows.lookup(v.ID).mappedAt) < fullscreenGrace {
1100					break
1101				}
1102				// A taskbar request is a user action on that window, as an
1103				// activation: it comes on screen with the focus, leaving
1104				// another window's fullscreen.
1105				if v.External && v.Fullscreen && c.activate(ctx, v.ID) != nil {
1106					return nil
1107				}
1108				if s, _ := c.screenOf(v.ID); s != nil {
1109					s.mon.SetFullscreen(v.ID, v.Fullscreen)
1110				}
1111			case ports.WorkspaceActivate:
1112				before := c.cur().mon.Current()
1113				for _, id := range v.IDs {
1114					for i, sc := range c.screens {
1115						if sc.name() == "" {
1116							continue
1117						}
1118						for w := range sc.mon.all() {
1119							if w.ID == id {
1120								c.focusScreen = i
1121								sc.mon.show(w)
1122								break
1123							}
1124						}
1125					}
1126				}
1127				if c.workspaceVisible(ctx, c.cur().mon.Current() != before) != nil {
1128					return nil
1129				}
1130			case ports.WindowActivate:
1131				if c.activate(ctx, v.ID) != nil {
1132					return nil
1133				}
1134			}
1135			if err := c.publish(ctx); err != nil {
1136				return nil
1137			}
1138		case f, ok := <-c.ch.Frames:
1139			if c.securityCheckpoint(ctx) != nil {
1140				return nil
1141			}
1142			if !ok {
1143				c.ch.Frames = nil
1144				continue
1145			}
1146			if c.sliding(f.Output) && c.step(ctx) != nil {
1147				return nil
1148			}
1149			continue
1150		case <-c.frameC:
1151			if c.securityCheckpoint(ctx) != nil {
1152				return nil
1153			}
1154			c.frameC, c.frameStop = nil, nil
1155			if c.step(ctx) != nil {
1156				return nil
1157			}
1158			continue
1159		case <-c.pulse.timerC:
1160			if c.securityCheckpoint(ctx) != nil {
1161				return nil
1162			}
1163			if c.pulseTick() && c.publish(ctx) != nil {
1164				return nil
1165			}
1166			continue
1167		case <-c.capt.timerC:
1168			c.capt.timerC, c.capt.timerStop = nil, nil
1169			if c.captureFlashTick() && c.publish(ctx) != nil {
1170				return nil
1171			}
1172			continue
1173		case ev, ok := <-c.ch.Output:
1174			if !ok {
1175				c.ch.Output = nil
1176				continue
1177			}
1178			if c.securityCheckpoint(ctx) != nil {
1179				return nil
1180			}
1181			switch v := ev.(type) {
1182			case ports.OutputAdded:
1183				c.addScreen(v.Info)
1184				if c.layerChanged {
1185					c.setLayers(c.allLayers())
1186				}
1187				c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
1188			case ports.OutputRemoved:
1189				// A drop target may be on the output going away.
1190				c.abortDrag()
1191				c.removeScreen(v.Name)
1192				if c.layerChanged {
1193					c.setLayers(c.allLayers())
1194				}
1195				c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
1196			}
1197			if c.workspaceVisible(ctx, false) != nil {
1198				return nil
1199			}
1200			if err := c.publish(ctx); err != nil {
1201				return nil
1202			}
1203		case ev, ok := <-c.ch.Config:
1204			if !ok {
1205				c.ch.Config = nil
1206				continue
1207			}
1208			if c.securityCheckpoint(ctx) != nil {
1209				return nil
1210			}
1211			if err := c.apply(ev.Config); err != nil {
1212				select {
1213				case c.ch.ConfigErrors <- err:
1214				default:
1215				}
1216				continue
1217			}
1218			// A rotation can shrink the layout under a still pointer.
1219			c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
1220			if c.workspaceVisible(ctx, false) != nil {
1221				return nil
1222			}
1223			if err := c.publish(ctx); err != nil {
1224				return nil
1225			}
1226		case ev, ok := <-c.ch.Input:
1227			if !ok {
1228				c.ch.Input = nil
1229				continue
1230			}
1231			if c.securityCheckpoint(ctx) != nil {
1232				return nil
1233			}
1234			// Even stale input is activity, but never becomes client input.
1235			if err := c.userActivity(ctx); err != nil {
1236				return nil
1237			}
1238			// Commands can backpressure; recheck after activity before admits/binds.
1239			if c.securityCheckpoint(ctx) != nil {
1240				return nil
1241			}
1242			var admitted bool
1243			ev, admitted = c.admitInput(ev)
1244			if !admitted {
1245				continue
1246			}
1247			if err := c.handleInput(ctx, ev); err != nil {
1248				if errors.Is(err, errSecurityChanged) {
1249					continue
1250				}
1251				return err
1252			}
1253		}
1254	}
1255}
1256
1257// workspaceVisible settles guests and refills slots on a show. Publication
1258// subsequently decides whether visible empty workspaces need terminals.
1259func (c *Core) workspaceVisible(ctx context.Context, shown bool) error {
1260	c.releaseSlots()
1261	c.settleGuests()
1262	return c.spawnSlots(ctx, shown)
1263}
1264
1265// spawnEmpty requests terminals for visible empty workspaces. publish calls
1266// it after the output layout and before scenes, so a terminal never maps
1267// before wayland knows its output.
1268func (c *Core) spawnEmpty(ctx context.Context) error {
1269	if c.protectionRequested() {
1270		return nil
1271	}
1272	first := c.firstTerminalResolved
1273	terms := c.fillEmpty()
1274	err := c.publishPending(ctx)
1275	for i, req := range terms {
1276		if err != nil || !c.trySpawn(ctx, req) {
1277			for _, suppressed := range terms[i:] {
1278				c.cancelTerminal(suppressed)
1279			}
1280			if i == 0 {
1281				c.firstTerminalResolved = first
1282			}
1283			return err
1284		}
1285	}
1286	return err
1287}
1288
1289// activate brings a window forward for a valid xdg-activation token. The
1290// token proves a user action in the requesting client (a click on a link,
1291// a notification), so unlike other client requests it may move the user
1292// (the exception to the ADR 011 golden rule).
1293func (c *Core) activate(ctx context.Context, id WindowID) error {
1294	s, w := c.screenOf(id)
1295	if s == nil || c.popups[id] != nil {
1296		return nil
1297	}
1298	before := c.cur().mon.Current()
1299	if w != s.mon.Current() {
1300		s.mon.show(w)
1301	}
1302	w.Activate(id)
1303	c.focusScreen = c.screenIndex(s.name())
1304	return c.workspaceVisible(ctx, c.cur().mon.Current() != before)
1305}
1306
1307// heldKey names a key for press tracking: by physical key, since Shift
1308// may be released before the key.
1309func heldKey(key ports.KeyEvent) string {
1310	if key.Keycode != 0 {
1311		return "#" + strconv.FormatUint(uint64(key.Keycode), 10)
1312	}
1313	return keyName(key.Keysym)
1314}

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