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