detached dfbf98e

feat(input): keyboard shortcuts inhibit and idle inhibit

Wayland reports windows with shortcuts or idle inhibitors to core. Core makes a window's shortcuts inhibitor active while the window has keyboard focus, tells wayland (active/inactive to the client) and forwards every key to it without matching binds; emergency quit and VT switch stay in input. Idle inhibitors appear in the state file per window.
commit
dfbf98e6c9e1abef010e1fd05f6cecc2b6f7f895
author
brice · · 2026-09-26 12:18 UTC
parent
2bcca8a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +478 -8
M internal/adapters/wayland/globals.go +1 -0
@@ -21,6 +21,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
2121 func() error { return registerPresentationConstraints(d, s) },
2222 func() error { return registerPresentation(d, s) },
2323 func() error { return registerSyncobj(d, s) },
24+ func() error { return registerInhibit(d, s) },
2425 func() error { return registerActivation(d, s) },
2526 func() error { return registerVirtualKeyboard(d, s) },
2627 func() error {
@@ -21,6 +21,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
21 func() error { return registerPresentationConstraints(d, s) },21 func() error { return registerPresentationConstraints(d, s) },
22 func() error { return registerPresentation(d, s) },22 func() error { return registerPresentation(d, s) },
23 func() error { return registerSyncobj(d, s) },23 func() error { return registerSyncobj(d, s) },
24+ func() error { return registerInhibit(d, s) },
24 func() error { return registerActivation(d, s) },25 func() error { return registerActivation(d, s) },
25 func() error { return registerVirtualKeyboard(d, s) },26 func() error { return registerVirtualKeyboard(d, s) },
26 func() error {27 func() error {
A internal/adapters/wayland/inhibit.go +163 -0
@@ -0,0 +1,163 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/nefertty/internal/ports"
5+ "github.com/bnema/purego-libwayland/protocol/idleinhibit"
6+ "github.com/bnema/purego-libwayland/protocol/keyboardshortcutsinhibit"
7+ "github.com/bnema/purego-libwayland/protocol/wayland"
8+ "github.com/bnema/purego-libwayland/server"
9+)
10+
11+// Inhibitors. keyboard-shortcuts-inhibit: a window asks for every key,
12+// compositor binds included, while it has keyboard focus; core decides
13+// when it is in effect (ports.ShortcutsInhibitState) and the client hears
14+// active/inactive. idle-inhibit: a window keeps the session awake while
15+// it is mapped. Both are reported to core per window.
16+
17+// inhibitor is one inhibitor object on a surface.
18+type inhibitor struct {
19+ surf *surface
20+ shortcut *keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitorV1 // nil: idle
21+ window ports.WindowID // reported to core (0: not yet)
22+ active bool // shortcuts: last state sent
23+}
24+
25+func registerInhibit(d *server.Display, s *Server) error {
26+ if err := keyboardshortcutsinhibit.NewZwpKeyboardShortcutsInhibitManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
27+ _, _ = keyboardshortcutsinhibit.NewZwpKeyboardShortcutsInhibitManagerV1(c, int32(v), id, shortcutsManager{s})
28+ }); err != nil {
29+ return err
30+ }
31+ return idleinhibit.NewZwpIdleInhibitManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
32+ _, _ = idleinhibit.NewZwpIdleInhibitManagerV1(c, int32(v), id, idleManager{s})
33+ })
34+}
35+
36+type shortcutsManager struct{ server *Server }
37+
38+func (shortcutsManager) Destroy(*keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1) {}
39+
40+// InhibitShortcuts adds a surface's inhibitor; one per surface and seat
41+// (there is one seat).
42+func (m shortcutsManager) InhibitShortcuts(r *keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1, id uint32, surf *wayland.Surface, _ *wayland.Seat) {
43+ state := m.server.surfaceOf(surf)
44+ if state == nil {
45+ return
46+ }
47+ for _, in := range m.server.inhibitors {
48+ if in.surf == state && in.shortcut != nil {
49+ r.PostError(uint32(keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1ErrorAlreadyInhibited), "surface already inhibits shortcuts")
50+ return
51+ }
52+ }
53+ res, err := keyboardshortcutsinhibit.NewZwpKeyboardShortcutsInhibitorV1(r.Client(), r.Version(), id, shortcutsInhibitor{})
54+ if err != nil {
55+ return
56+ }
57+ in := &inhibitor{surf: state, shortcut: res}
58+ m.server.addInhibitor(in)
59+ res.OnDestroy = func() { m.server.removeInhibitor(in) }
60+}
61+
62+type shortcutsInhibitor struct{}
63+
64+func (shortcutsInhibitor) Destroy(*keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitorV1) {}
65+
66+type idleManager struct{ server *Server }
67+
68+func (idleManager) Destroy(*idleinhibit.ZwpIdleInhibitManagerV1) {}
69+
70+// CreateInhibitor adds an idle inhibitor; a surface may have several.
71+func (m idleManager) CreateInhibitor(r *idleinhibit.ZwpIdleInhibitManagerV1, id uint32, surf *wayland.Surface) {
72+ state := m.server.surfaceOf(surf)
73+ if state == nil {
74+ return
75+ }
76+ res, err := idleinhibit.NewZwpIdleInhibitorV1(r.Client(), r.Version(), id, idleInhibitor{})
77+ if err != nil {
78+ return
79+ }
80+ in := &inhibitor{surf: state}
81+ m.server.addInhibitor(in)
82+ res.OnDestroy = func() { m.server.removeInhibitor(in) }
83+}
84+
85+type idleInhibitor struct{}
86+
87+func (idleInhibitor) Destroy(*idleinhibit.ZwpIdleInhibitorV1) {}
88+
89+func (s *Server) addInhibitor(in *inhibitor) {
90+ s.inhibitors = append(s.inhibitors, in)
91+ s.syncInhibitors()
92+}
93+
94+func (s *Server) removeInhibitor(in *inhibitor) {
95+ for i, x := range s.inhibitors {
96+ if x == in {
97+ s.inhibitors = append(s.inhibitors[:i], s.inhibitors[i+1:]...)
98+ break
99+ }
100+ }
101+ in.surf = nil
102+ s.syncInhibitors()
103+}
104+
105+// syncInhibitors tells core, per window, whether it has inhibitors of
106+// each kind, after any change: an inhibitor added or removed, a window
107+// mapped or unmapped.
108+func (s *Server) syncInhibitors() {
109+ shortcuts, idle := map[ports.WindowID]bool{}, map[ports.WindowID]bool{}
110+ for _, in := range s.inhibitors {
111+ in.window = 0
112+ if in.surf != nil && !in.surf.destroyed {
113+ in.window = in.surf.root().windowID()
114+ }
115+ if in.window == 0 {
116+ continue
117+ }
118+ if in.shortcut != nil {
119+ shortcuts[in.window] = true
120+ } else {
121+ idle[in.window] = true
122+ }
123+ }
124+ for id, on := range diffSets(s.shortcutWindows, shortcuts) {
125+ s.emit(ports.ShortcutsInhibit{Window: id, Active: on})
126+ }
127+ for id, on := range diffSets(s.idleWindows, idle) {
128+ s.emit(ports.IdleInhibit{Window: id, Active: on})
129+ }
130+ s.shortcutWindows, s.idleWindows = shortcuts, idle
131+}
132+
133+// diffSets is the membership changes from old to cur.
134+func diffSets(old, cur map[ports.WindowID]bool) map[ports.WindowID]bool {
135+ out := map[ports.WindowID]bool{}
136+ for id := range cur {
137+ if !old[id] {
138+ out[id] = true
139+ }
140+ }
141+ for id := range old {
142+ if !cur[id] {
143+ out[id] = false
144+ }
145+ }
146+ return out
147+}
148+
149+// setShortcutsInhibit applies core's decision to a window's shortcuts
150+// inhibitor.
151+func (s *Server) setShortcutsInhibit(c ports.ShortcutsInhibitState) {
152+ for _, in := range s.inhibitors {
153+ if in.shortcut == nil || in.window != c.Window || in.active == c.Active || !in.shortcut.Resource.Alive() {
154+ continue
155+ }
156+ in.active = c.Active
157+ if c.Active {
158+ in.shortcut.SendActive()
159+ } else {
160+ in.shortcut.SendInactive()
161+ }
162+ }
163+}
@@ -0,0 +1,163 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/nefertty/internal/ports"
5+ "github.com/bnema/purego-libwayland/protocol/idleinhibit"
6+ "github.com/bnema/purego-libwayland/protocol/keyboardshortcutsinhibit"
7+ "github.com/bnema/purego-libwayland/protocol/wayland"
8+ "github.com/bnema/purego-libwayland/server"
9+)
10+
11+// Inhibitors. keyboard-shortcuts-inhibit: a window asks for every key,
12+// compositor binds included, while it has keyboard focus; core decides
13+// when it is in effect (ports.ShortcutsInhibitState) and the client hears
14+// active/inactive. idle-inhibit: a window keeps the session awake while
15+// it is mapped. Both are reported to core per window.
16+
17+// inhibitor is one inhibitor object on a surface.
18+type inhibitor struct {
19+ surf *surface
20+ shortcut *keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitorV1 // nil: idle
21+ window ports.WindowID // reported to core (0: not yet)
22+ active bool // shortcuts: last state sent
23+}
24+
25+func registerInhibit(d *server.Display, s *Server) error {
26+ if err := keyboardshortcutsinhibit.NewZwpKeyboardShortcutsInhibitManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
27+ _, _ = keyboardshortcutsinhibit.NewZwpKeyboardShortcutsInhibitManagerV1(c, int32(v), id, shortcutsManager{s})
28+ }); err != nil {
29+ return err
30+ }
31+ return idleinhibit.NewZwpIdleInhibitManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
32+ _, _ = idleinhibit.NewZwpIdleInhibitManagerV1(c, int32(v), id, idleManager{s})
33+ })
34+}
35+
36+type shortcutsManager struct{ server *Server }
37+
38+func (shortcutsManager) Destroy(*keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1) {}
39+
40+// InhibitShortcuts adds a surface's inhibitor; one per surface and seat
41+// (there is one seat).
42+func (m shortcutsManager) InhibitShortcuts(r *keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1, id uint32, surf *wayland.Surface, _ *wayland.Seat) {
43+ state := m.server.surfaceOf(surf)
44+ if state == nil {
45+ return
46+ }
47+ for _, in := range m.server.inhibitors {
48+ if in.surf == state && in.shortcut != nil {
49+ r.PostError(uint32(keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1ErrorAlreadyInhibited), "surface already inhibits shortcuts")
50+ return
51+ }
52+ }
53+ res, err := keyboardshortcutsinhibit.NewZwpKeyboardShortcutsInhibitorV1(r.Client(), r.Version(), id, shortcutsInhibitor{})
54+ if err != nil {
55+ return
56+ }
57+ in := &inhibitor{surf: state, shortcut: res}
58+ m.server.addInhibitor(in)
59+ res.OnDestroy = func() { m.server.removeInhibitor(in) }
60+}
61+
62+type shortcutsInhibitor struct{}
63+
64+func (shortcutsInhibitor) Destroy(*keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitorV1) {}
65+
66+type idleManager struct{ server *Server }
67+
68+func (idleManager) Destroy(*idleinhibit.ZwpIdleInhibitManagerV1) {}
69+
70+// CreateInhibitor adds an idle inhibitor; a surface may have several.
71+func (m idleManager) CreateInhibitor(r *idleinhibit.ZwpIdleInhibitManagerV1, id uint32, surf *wayland.Surface) {
72+ state := m.server.surfaceOf(surf)
73+ if state == nil {
74+ return
75+ }
76+ res, err := idleinhibit.NewZwpIdleInhibitorV1(r.Client(), r.Version(), id, idleInhibitor{})
77+ if err != nil {
78+ return
79+ }
80+ in := &inhibitor{surf: state}
81+ m.server.addInhibitor(in)
82+ res.OnDestroy = func() { m.server.removeInhibitor(in) }
83+}
84+
85+type idleInhibitor struct{}
86+
87+func (idleInhibitor) Destroy(*idleinhibit.ZwpIdleInhibitorV1) {}
88+
89+func (s *Server) addInhibitor(in *inhibitor) {
90+ s.inhibitors = append(s.inhibitors, in)
91+ s.syncInhibitors()
92+}
93+
94+func (s *Server) removeInhibitor(in *inhibitor) {
95+ for i, x := range s.inhibitors {
96+ if x == in {
97+ s.inhibitors = append(s.inhibitors[:i], s.inhibitors[i+1:]...)
98+ break
99+ }
100+ }
101+ in.surf = nil
102+ s.syncInhibitors()
103+}
104+
105+// syncInhibitors tells core, per window, whether it has inhibitors of
106+// each kind, after any change: an inhibitor added or removed, a window
107+// mapped or unmapped.
108+func (s *Server) syncInhibitors() {
109+ shortcuts, idle := map[ports.WindowID]bool{}, map[ports.WindowID]bool{}
110+ for _, in := range s.inhibitors {
111+ in.window = 0
112+ if in.surf != nil && !in.surf.destroyed {
113+ in.window = in.surf.root().windowID()
114+ }
115+ if in.window == 0 {
116+ continue
117+ }
118+ if in.shortcut != nil {
119+ shortcuts[in.window] = true
120+ } else {
121+ idle[in.window] = true
122+ }
123+ }
124+ for id, on := range diffSets(s.shortcutWindows, shortcuts) {
125+ s.emit(ports.ShortcutsInhibit{Window: id, Active: on})
126+ }
127+ for id, on := range diffSets(s.idleWindows, idle) {
128+ s.emit(ports.IdleInhibit{Window: id, Active: on})
129+ }
130+ s.shortcutWindows, s.idleWindows = shortcuts, idle
131+}
132+
133+// diffSets is the membership changes from old to cur.
134+func diffSets(old, cur map[ports.WindowID]bool) map[ports.WindowID]bool {
135+ out := map[ports.WindowID]bool{}
136+ for id := range cur {
137+ if !old[id] {
138+ out[id] = true
139+ }
140+ }
141+ for id := range old {
142+ if !cur[id] {
143+ out[id] = false
144+ }
145+ }
146+ return out
147+}
148+
149+// setShortcutsInhibit applies core's decision to a window's shortcuts
150+// inhibitor.
151+func (s *Server) setShortcutsInhibit(c ports.ShortcutsInhibitState) {
152+ for _, in := range s.inhibitors {
153+ if in.shortcut == nil || in.window != c.Window || in.active == c.Active || !in.shortcut.Resource.Alive() {
154+ continue
155+ }
156+ in.active = c.Active
157+ if c.Active {
158+ in.shortcut.SendActive()
159+ } else {
160+ in.shortcut.SendInactive()
161+ }
162+ }
163+}
A internal/adapters/wayland/inhibit_test.go +118 -0
@@ -0,0 +1,118 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/idleinhibit"
9+ "github.com/bnema/purego-libwayland/protocol/keyboardshortcutsinhibit"
10+ "github.com/bnema/wlturbo"
11+)
12+
13+type inhibitorProxy struct {
14+ wlturbo.BaseProxy
15+ states chan bool
16+}
17+
18+func (p *inhibitorProxy) Dispatch(e *wlturbo.Event) {
19+ p.states <- uint32(e.Opcode) == keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitorV1EventActive
20+}
21+
22+// clientEvent waits for the next client event of type T.
23+func clientEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientEvent) T {
24+ t.Helper()
25+ deadline := time.After(2 * time.Second)
26+ for {
27+ select {
28+ case ev := <-events:
29+ if v, ok := ev.(T); ok {
30+ return v
31+ }
32+ case <-deadline:
33+ var zero T
34+ t.Fatalf("no %T", zero)
35+ return zero
36+ }
37+ }
38+}
39+
40+func TestShortcutsInhibitor(t *testing.T) {
41+ s, events, commands, _, dir := contentServer(t)
42+ c := protocolClient(t, s, dir)
43+ w, surf, _ := surfaceMapper(t, c, events)()
44+ seat := bindProtocol(t, c, "wl_seat")
45+ registerProtocol(t, c, seat)
46+ mgr := bindProtocol(t, c, "zwp_keyboard_shortcuts_inhibit_manager_v1")
47+ in := c.AllocateID()
48+ p := &inhibitorProxy{states: make(chan bool, 4)}
49+ p.SetID(in)
50+ c.Context().Register(p)
51+ requestProtocol(t, c, mgr, keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1RequestInhibitShortcuts, in, surf, seat)
52+ if v := clientEvent[ports.ShortcutsInhibit](t, events); v != (ports.ShortcutsInhibit{Window: w.ID, Active: true}) {
53+ t.Fatal(v)
54+ }
55+ // Core decides: active, then inactive.
56+ state := func(active bool) {
57+ t.Helper()
58+ commands <- ports.ShortcutsInhibitState{Window: w.ID, Active: active}
59+ deadline := time.Now().Add(2 * time.Second)
60+ for time.Now().Before(deadline) {
61+ if err := c.Roundtrip(); err != nil {
62+ t.Fatal(err)
63+ }
64+ select {
65+ case got := <-p.states:
66+ if got != active {
67+ t.Fatalf("state %v", got)
68+ }
69+ return
70+ case <-time.After(5 * time.Millisecond):
71+ }
72+ }
73+ t.Fatal("no active/inactive event")
74+ }
75+ state(true)
76+ state(false)
77+ // Destroying it tells core.
78+ requestProtocol(t, c, in, keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitorV1RequestDestroy)
79+ if v := clientEvent[ports.ShortcutsInhibit](t, events); v.Active {
80+ t.Fatal(v)
81+ }
82+ // A second inhibitor on the same surface is a protocol error.
83+ requestProtocol(t, c, mgr, keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1RequestInhibitShortcuts, c.AllocateID(), surf, seat)
84+ requestProtocol(t, c, mgr, keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1RequestInhibitShortcuts, c.AllocateID(), surf, seat)
85+ expectProtocolError(t, c, mgr, uint32(keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1ErrorAlreadyInhibited))
86+}
87+
88+// Idle inhibitors: several per surface, the window is reported once and
89+// released when the last one goes.
90+func TestIdleInhibitors(t *testing.T) {
91+ s, events, _, _, dir := contentServer(t)
92+ c := protocolClient(t, s, dir)
93+ w, surf, _ := surfaceMapper(t, c, events)()
94+ mgr := bindProtocol(t, c, "zwp_idle_inhibit_manager_v1")
95+ a, b := c.AllocateID(), c.AllocateID()
96+ registerProtocol(t, c, a)
97+ registerProtocol(t, c, b)
98+ requestProtocol(t, c, mgr, idleinhibit.ZwpIdleInhibitManagerV1RequestCreateInhibitor, a, surf)
99+ requestProtocol(t, c, mgr, idleinhibit.ZwpIdleInhibitManagerV1RequestCreateInhibitor, b, surf)
100+ if v := clientEvent[ports.IdleInhibit](t, events); v != (ports.IdleInhibit{Window: w.ID, Active: true}) {
101+ t.Fatal(v)
102+ }
103+ requestProtocol(t, c, a, idleinhibit.ZwpIdleInhibitorV1RequestDestroy)
104+ requestProtocol(t, c, b, idleinhibit.ZwpIdleInhibitorV1RequestDestroy)
105+ if v := clientEvent[ports.IdleInhibit](t, events); v.Active {
106+ t.Fatal(v)
107+ }
108+ if err := c.Roundtrip(); err != nil {
109+ t.Fatal(err)
110+ }
111+ select {
112+ case ev := <-events:
113+ if _, ok := ev.(ports.IdleInhibit); ok {
114+ t.Fatalf("extra %+v", ev)
115+ }
116+ default:
117+ }
118+}
@@ -0,0 +1,118 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/idleinhibit"
9+ "github.com/bnema/purego-libwayland/protocol/keyboardshortcutsinhibit"
10+ "github.com/bnema/wlturbo"
11+)
12+
13+type inhibitorProxy struct {
14+ wlturbo.BaseProxy
15+ states chan bool
16+}
17+
18+func (p *inhibitorProxy) Dispatch(e *wlturbo.Event) {
19+ p.states <- uint32(e.Opcode) == keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitorV1EventActive
20+}
21+
22+// clientEvent waits for the next client event of type T.
23+func clientEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientEvent) T {
24+ t.Helper()
25+ deadline := time.After(2 * time.Second)
26+ for {
27+ select {
28+ case ev := <-events:
29+ if v, ok := ev.(T); ok {
30+ return v
31+ }
32+ case <-deadline:
33+ var zero T
34+ t.Fatalf("no %T", zero)
35+ return zero
36+ }
37+ }
38+}
39+
40+func TestShortcutsInhibitor(t *testing.T) {
41+ s, events, commands, _, dir := contentServer(t)
42+ c := protocolClient(t, s, dir)
43+ w, surf, _ := surfaceMapper(t, c, events)()
44+ seat := bindProtocol(t, c, "wl_seat")
45+ registerProtocol(t, c, seat)
46+ mgr := bindProtocol(t, c, "zwp_keyboard_shortcuts_inhibit_manager_v1")
47+ in := c.AllocateID()
48+ p := &inhibitorProxy{states: make(chan bool, 4)}
49+ p.SetID(in)
50+ c.Context().Register(p)
51+ requestProtocol(t, c, mgr, keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1RequestInhibitShortcuts, in, surf, seat)
52+ if v := clientEvent[ports.ShortcutsInhibit](t, events); v != (ports.ShortcutsInhibit{Window: w.ID, Active: true}) {
53+ t.Fatal(v)
54+ }
55+ // Core decides: active, then inactive.
56+ state := func(active bool) {
57+ t.Helper()
58+ commands <- ports.ShortcutsInhibitState{Window: w.ID, Active: active}
59+ deadline := time.Now().Add(2 * time.Second)
60+ for time.Now().Before(deadline) {
61+ if err := c.Roundtrip(); err != nil {
62+ t.Fatal(err)
63+ }
64+ select {
65+ case got := <-p.states:
66+ if got != active {
67+ t.Fatalf("state %v", got)
68+ }
69+ return
70+ case <-time.After(5 * time.Millisecond):
71+ }
72+ }
73+ t.Fatal("no active/inactive event")
74+ }
75+ state(true)
76+ state(false)
77+ // Destroying it tells core.
78+ requestProtocol(t, c, in, keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitorV1RequestDestroy)
79+ if v := clientEvent[ports.ShortcutsInhibit](t, events); v.Active {
80+ t.Fatal(v)
81+ }
82+ // A second inhibitor on the same surface is a protocol error.
83+ requestProtocol(t, c, mgr, keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1RequestInhibitShortcuts, c.AllocateID(), surf, seat)
84+ requestProtocol(t, c, mgr, keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1RequestInhibitShortcuts, c.AllocateID(), surf, seat)
85+ expectProtocolError(t, c, mgr, uint32(keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1ErrorAlreadyInhibited))
86+}
87+
88+// Idle inhibitors: several per surface, the window is reported once and
89+// released when the last one goes.
90+func TestIdleInhibitors(t *testing.T) {
91+ s, events, _, _, dir := contentServer(t)
92+ c := protocolClient(t, s, dir)
93+ w, surf, _ := surfaceMapper(t, c, events)()
94+ mgr := bindProtocol(t, c, "zwp_idle_inhibit_manager_v1")
95+ a, b := c.AllocateID(), c.AllocateID()
96+ registerProtocol(t, c, a)
97+ registerProtocol(t, c, b)
98+ requestProtocol(t, c, mgr, idleinhibit.ZwpIdleInhibitManagerV1RequestCreateInhibitor, a, surf)
99+ requestProtocol(t, c, mgr, idleinhibit.ZwpIdleInhibitManagerV1RequestCreateInhibitor, b, surf)
100+ if v := clientEvent[ports.IdleInhibit](t, events); v != (ports.IdleInhibit{Window: w.ID, Active: true}) {
101+ t.Fatal(v)
102+ }
103+ requestProtocol(t, c, a, idleinhibit.ZwpIdleInhibitorV1RequestDestroy)
104+ requestProtocol(t, c, b, idleinhibit.ZwpIdleInhibitorV1RequestDestroy)
105+ if v := clientEvent[ports.IdleInhibit](t, events); v.Active {
106+ t.Fatal(v)
107+ }
108+ if err := c.Roundtrip(); err != nil {
109+ t.Fatal(err)
110+ }
111+ select {
112+ case ev := <-events:
113+ if _, ok := ev.(ports.IdleInhibit); ok {
114+ t.Fatalf("extra %+v", ev)
115+ }
116+ default:
117+ }
118+}
M internal/adapters/wayland/server.go +12 -6
@@ -124,12 +124,16 @@ type Server struct {
124124 timelines map[*server.Resource]*timeline
125125 syncWait *syncWaiter
126126 // flips is each output's latest flip (pacer, under Do).
127- flips map[string]ports.FlipInfo
128- contentNotify chan struct{}
129- contentReady chan struct{}
130- outputs []*output
131- focusedOutput string
132- fractions map[*surface]*fractionalscale.WpFractionalScaleV1
127+ flips map[string]ports.FlipInfo
128+ // inhibitors are the live inhibitor objects (inhibit.go);
129+ // shortcutWindows and idleWindows what core was told.
130+ inhibitors []*inhibitor
131+ shortcutWindows, idleWindows map[ports.WindowID]bool
132+ contentNotify chan struct{}
133+ contentReady chan struct{}
134+ outputs []*output
135+ focusedOutput string
136+ fractions map[*surface]*fractionalscale.WpFractionalScaleV1
133137 // cursorSurface is the wl_pointer.set_cursor surface in use.
134138 cursorSurface *surface
135139 // cursorMu guards only the latest cursor change for forwardCursors.
@@ -578,6 +582,8 @@ func (s *Server) apply(cmd ports.ClientCommand) {
578582 s.setOutputs(c)
579583 case ports.SlotsPending:
580584 s.slotsPending = c.Pending
585+ case ports.ShortcutsInhibitState:
586+ s.setShortcutsInhibit(c)
581587 case ports.CloseWindow:
582588 w := s.windows[c.ID]
583589 if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() {
@@ -124,12 +124,16 @@ type Server struct {
124 timelines map[*server.Resource]*timeline124 timelines map[*server.Resource]*timeline
125 syncWait *syncWaiter125 syncWait *syncWaiter
126 // flips is each output's latest flip (pacer, under Do).126 // flips is each output's latest flip (pacer, under Do).
127- flips map[string]ports.FlipInfo127+ flips map[string]ports.FlipInfo
128- contentNotify chan struct{}128+ // inhibitors are the live inhibitor objects (inhibit.go);
129- contentReady chan struct{}129+ // shortcutWindows and idleWindows what core was told.
130- outputs []*output130+ inhibitors []*inhibitor
131- focusedOutput string131+ shortcutWindows, idleWindows map[ports.WindowID]bool
132- fractions map[*surface]*fractionalscale.WpFractionalScaleV1132+ contentNotify chan struct{}
133+ contentReady chan struct{}
134+ outputs []*output
135+ focusedOutput string
136+ fractions map[*surface]*fractionalscale.WpFractionalScaleV1
133 // cursorSurface is the wl_pointer.set_cursor surface in use.137 // cursorSurface is the wl_pointer.set_cursor surface in use.
134 cursorSurface *surface138 cursorSurface *surface
135 // cursorMu guards only the latest cursor change for forwardCursors.139 // cursorMu guards only the latest cursor change for forwardCursors.
@@ -578,6 +582,8 @@ func (s *Server) apply(cmd ports.ClientCommand) {
578 s.setOutputs(c)582 s.setOutputs(c)
579 case ports.SlotsPending:583 case ports.SlotsPending:
580 s.slotsPending = c.Pending584 s.slotsPending = c.Pending
585+ case ports.ShortcutsInhibitState:
586+ s.setShortcutsInhibit(c)
581 case ports.CloseWindow:587 case ports.CloseWindow:
582 w := s.windows[c.ID]588 w := s.windows[c.ID]
583 if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() {589 if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() {
M internal/adapters/wayland/xdg.go +2 -0
@@ -182,6 +182,7 @@ func (w *window) unmap() {
182182 w.xdg.acked = false
183183 w.xdg.serials = nil
184184 w.xdg.server.emit(ports.WindowUnmapped{ID: w.id})
185+ w.xdg.server.syncInhibitors()
185186 w.xdg.server.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true})
186187 }
187188 func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
@@ -215,6 +216,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
215216 // Its size comes with the commit, in WindowResized (afterCommit).
216217 w.floating = w.floats()
217218 x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH})
219+ x.server.syncInhibitors()
218220 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")
219221 } else if !buffer && w.mapped {
220222 w.unmap()
@@ -182,6 +182,7 @@ func (w *window) unmap() {
182 w.xdg.acked = false182 w.xdg.acked = false
183 w.xdg.serials = nil183 w.xdg.serials = nil
184 w.xdg.server.emit(ports.WindowUnmapped{ID: w.id})184 w.xdg.server.emit(ports.WindowUnmapped{ID: w.id})
185+ w.xdg.server.syncInhibitors()
185 w.xdg.server.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true})186 w.xdg.server.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true})
186 }187 }
187 func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {188 func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
@@ -215,6 +216,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
215 // Its size comes with the commit, in WindowResized (afterCommit).216 // Its size comes with the commit, in WindowResized (afterCommit).
216 w.floating = w.floats()217 w.floating = w.floats()
217 x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH})218 x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH})
219+ x.server.syncInhibitors()
218 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")220 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")
219 } else if !buffer && w.mapped {221 } else if !buffer && w.mapped {
220 w.unmap()222 w.unmap()
M internal/core/core.go +61 -1
@@ -89,6 +89,12 @@ type Core struct {
8989 // pointer moving to another output does not.
9090 layerFocus WindowID
9191 layerOver map[*screen]WindowID
92+ // inhibitors are the windows asking to keep compositor binds while
93+ // focused, inhibiting the one active now (0: none); idle are those
94+ // keeping the session awake.
95+ inhibitors map[WindowID]bool
96+ inhibiting WindowID
97+ idle map[WindowID]bool
9298 }
9399
94100 func keyName(s string) string {
@@ -271,7 +277,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
271277 return nil, fmt.Errorf("scenes, layouts, constraints and state must have capacity 1")
272278 }
273279 // A placeholder screen holds windows until the first output arrives.
274- c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
280+ c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}, inhibitors: map[WindowID]bool{}, idle: map[WindowID]bool{}}
275281 if err := c.apply(cfg); err != nil {
276282 return nil, err
277283 }
@@ -448,12 +454,37 @@ func (c *Core) publish(ctx context.Context) error {
448454 }
449455 c.focus = focus
450456 }
457+ if err := c.updateInhibit(ctx); err != nil {
458+ return err
459+ }
451460 c.resolveConstraint()
452461 latest(c.ch.Scenes, scenes)
453462 c.publishState()
454463 return nil
455464 }
456465
466+// updateInhibit makes the shortcuts inhibitor of the keyboard focus the
467+// active one and tells wayland when it changes.
468+func (c *Core) updateInhibit(ctx context.Context) error {
469+ want := WindowID(0)
470+ if f := c.keyboardFocus(); f != 0 && c.inhibitors[f] {
471+ want = f
472+ }
473+ if want == c.inhibiting {
474+ return nil
475+ }
476+ if c.inhibiting != 0 {
477+ if err := c.command(ctx, ports.ShortcutsInhibitState{Window: c.inhibiting}); err != nil {
478+ return err
479+ }
480+ }
481+ c.inhibiting = want
482+ if want != 0 {
483+ return c.command(ctx, ports.ShortcutsInhibitState{Window: want, Active: true})
484+ }
485+ return nil
486+}
487+
457488 // resolveConstraint turns the active constraint into global coordinates
458489 // and sends it to input when it changed. A hidden window holds nothing.
459490 func (c *Core) resolveConstraint() {
@@ -616,6 +647,24 @@ func (c *Core) Run(ctx context.Context) error {
616647 if p := c.popups[v.ID]; p != nil {
617648 p.mapped = true
618649 }
650+ case ports.ShortcutsInhibit:
651+ if v.Active {
652+ c.inhibitors[v.Window] = true
653+ } else {
654+ delete(c.inhibitors, v.Window)
655+ }
656+ if err := c.updateInhibit(ctx); err != nil {
657+ return nil
658+ }
659+ continue
660+ case ports.IdleInhibit:
661+ if v.Active {
662+ c.idle[v.Window] = true
663+ } else {
664+ delete(c.idle, v.Window)
665+ }
666+ c.publishState()
667+ continue
619668 case ports.WindowAppID:
620669 if info, ok := c.clients[v.ID]; ok {
621670 info.AppID = v.AppID
@@ -631,6 +680,8 @@ func (c *Core) Run(ctx context.Context) error {
631680 return nil
632681 }
633682 delete(c.clients, v.ID)
683+ delete(c.inhibitors, v.ID)
684+ delete(c.idle, v.ID)
634685 if s, _ := c.screenOf(v.ID); s != nil {
635686 s.mon.RemoveWindow(v.ID)
636687 }
@@ -797,6 +848,15 @@ func (c *Core) Run(ctx context.Context) error {
797848 if !ok {
798849 continue
799850 }
851+ // A focused window inhibiting shortcuts gets every key:
852+ // no bind runs (emergency quit and VT switch are handled by
853+ // input before core).
854+ if c.inhibiting != 0 && c.inhibiting == c.keyboardFocus() {
855+ if err := c.command(ctx, ports.ForwardKey{ID: c.inhibiting, Key: key}); err != nil {
856+ return nil
857+ }
858+ continue
859+ }
800860 name := keyName(key.Keysym)
801861 // A bind on the keysym wins; then the unshifted keysym (Cmd+Shift+1
802862 // prints exclam on US); then the physical key (code:N).
@@ -89,6 +89,12 @@ type Core struct {
89 // pointer moving to another output does not.89 // pointer moving to another output does not.
90 layerFocus WindowID90 layerFocus WindowID
91 layerOver map[*screen]WindowID91 layerOver map[*screen]WindowID
92+ // inhibitors are the windows asking to keep compositor binds while
93+ // focused, inhibiting the one active now (0: none); idle are those
94+ // keeping the session awake.
95+ inhibitors map[WindowID]bool
96+ inhibiting WindowID
97+ idle map[WindowID]bool
92 }98 }
93 99
94 func keyName(s string) string {100 func keyName(s string) string {
@@ -271,7 +277,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
271 return nil, fmt.Errorf("scenes, layouts, constraints and state must have capacity 1")277 return nil, fmt.Errorf("scenes, layouts, constraints and state must have capacity 1")
272 }278 }
273 // A placeholder screen holds windows until the first output arrives.279 // A placeholder screen holds windows until the first output arrives.
274- c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}280+ c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}, inhibitors: map[WindowID]bool{}, idle: map[WindowID]bool{}}
275 if err := c.apply(cfg); err != nil {281 if err := c.apply(cfg); err != nil {
276 return nil, err282 return nil, err
277 }283 }
@@ -448,12 +454,37 @@ func (c *Core) publish(ctx context.Context) error {
448 }454 }
449 c.focus = focus455 c.focus = focus
450 }456 }
457+ if err := c.updateInhibit(ctx); err != nil {
458+ return err
459+ }
451 c.resolveConstraint()460 c.resolveConstraint()
452 latest(c.ch.Scenes, scenes)461 latest(c.ch.Scenes, scenes)
453 c.publishState()462 c.publishState()
454 return nil463 return nil
455 }464 }
456 465
466+// updateInhibit makes the shortcuts inhibitor of the keyboard focus the
467+// active one and tells wayland when it changes.
468+func (c *Core) updateInhibit(ctx context.Context) error {
469+ want := WindowID(0)
470+ if f := c.keyboardFocus(); f != 0 && c.inhibitors[f] {
471+ want = f
472+ }
473+ if want == c.inhibiting {
474+ return nil
475+ }
476+ if c.inhibiting != 0 {
477+ if err := c.command(ctx, ports.ShortcutsInhibitState{Window: c.inhibiting}); err != nil {
478+ return err
479+ }
480+ }
481+ c.inhibiting = want
482+ if want != 0 {
483+ return c.command(ctx, ports.ShortcutsInhibitState{Window: want, Active: true})
484+ }
485+ return nil
486+}
487+
457 // resolveConstraint turns the active constraint into global coordinates488 // resolveConstraint turns the active constraint into global coordinates
458 // and sends it to input when it changed. A hidden window holds nothing.489 // and sends it to input when it changed. A hidden window holds nothing.
459 func (c *Core) resolveConstraint() {490 func (c *Core) resolveConstraint() {
@@ -616,6 +647,24 @@ func (c *Core) Run(ctx context.Context) error {
616 if p := c.popups[v.ID]; p != nil {647 if p := c.popups[v.ID]; p != nil {
617 p.mapped = true648 p.mapped = true
618 }649 }
650+ case ports.ShortcutsInhibit:
651+ if v.Active {
652+ c.inhibitors[v.Window] = true
653+ } else {
654+ delete(c.inhibitors, v.Window)
655+ }
656+ if err := c.updateInhibit(ctx); err != nil {
657+ return nil
658+ }
659+ continue
660+ case ports.IdleInhibit:
661+ if v.Active {
662+ c.idle[v.Window] = true
663+ } else {
664+ delete(c.idle, v.Window)
665+ }
666+ c.publishState()
667+ continue
619 case ports.WindowAppID:668 case ports.WindowAppID:
620 if info, ok := c.clients[v.ID]; ok {669 if info, ok := c.clients[v.ID]; ok {
621 info.AppID = v.AppID670 info.AppID = v.AppID
@@ -631,6 +680,8 @@ func (c *Core) Run(ctx context.Context) error {
631 return nil680 return nil
632 }681 }
633 delete(c.clients, v.ID)682 delete(c.clients, v.ID)
683+ delete(c.inhibitors, v.ID)
684+ delete(c.idle, v.ID)
634 if s, _ := c.screenOf(v.ID); s != nil {685 if s, _ := c.screenOf(v.ID); s != nil {
635 s.mon.RemoveWindow(v.ID)686 s.mon.RemoveWindow(v.ID)
636 }687 }
@@ -797,6 +848,15 @@ func (c *Core) Run(ctx context.Context) error {
797 if !ok {848 if !ok {
798 continue849 continue
799 }850 }
851+ // A focused window inhibiting shortcuts gets every key:
852+ // no bind runs (emergency quit and VT switch are handled by
853+ // input before core).
854+ if c.inhibiting != 0 && c.inhibiting == c.keyboardFocus() {
855+ if err := c.command(ctx, ports.ForwardKey{ID: c.inhibiting, Key: key}); err != nil {
856+ return nil
857+ }
858+ continue
859+ }
800 name := keyName(key.Keysym)860 name := keyName(key.Keysym)
801 // A bind on the keysym wins; then the unshifted keysym (Cmd+Shift+1861 // A bind on the keysym wins; then the unshifted keysym (Cmd+Shift+1
802 // prints exclam on US); then the physical key (code:N).862 // prints exclam on US); then the physical key (code:N).
A internal/core/inhibit_test.go +90 -0
@@ -0,0 +1,90 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/nefertty/internal/adapters/config"
9+ "github.com/bnema/nefertty/internal/core"
10+ "github.com/bnema/nefertty/internal/ports"
11+)
12+
13+// commandOf waits for the next command of type T, skipping others.
14+func commandOf[T ports.ClientCommand](t *testing.T, commands <-chan ports.ClientCommand) T {
15+ t.Helper()
16+ deadline := time.After(2 * time.Second)
17+ for {
18+ select {
19+ case c := <-commands:
20+ if v, ok := c.(T); ok {
21+ return v
22+ }
23+ case <-deadline:
24+ var zero T
25+ t.Fatalf("no %T", zero)
26+ return zero
27+ }
28+ }
29+}
30+
31+// A focused window inhibiting shortcuts gets bound keys; it stops when
32+// it loses focus, and binds run again.
33+func TestShortcutsInhibit(t *testing.T) {
34+ cfg := config.Defaults()
35+ input := make(chan ports.InputEvent, 4)
36+ client := make(chan ports.ClientEvent, 4)
37+ commands := make(chan ports.ClientCommand, 64)
38+ scenes := make(chan []ports.Scene, 1)
39+ spawn := make(chan ports.SpawnRequest, 4)
40+ state := make(chan ports.State, 1)
41+ output := make(chan ports.OutputEvent, 1)
42+ c, err := core.New(cfg, core.Channels{Input: input, Client: client, Output: output, Commands: commands, Scenes: scenes, Spawn: spawn, State: state})
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ ctx, cancel := context.WithCancel(context.Background())
47+ done := make(chan error, 1)
48+ go func() { done <- c.Run(ctx) }()
49+ defer func() { cancel(); receive(t, done) }()
50+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
51+ scene(t, scenes)
52+ client <- ports.WindowMapped{ID: 1}
53+ scene(t, scenes)
54+ client <- ports.ShortcutsInhibit{Window: 1, Active: true}
55+ if v := commandOf[ports.ShortcutsInhibitState](t, commands); v != (ports.ShortcutsInhibitState{Window: 1, Active: true}) {
56+ t.Fatal(v)
57+ }
58+ bound := ports.KeyEvent{Keysym: "Return", Mods: ports.ModSuper, Pressed: true}
59+ input <- bound
60+ if v := commandOf[ports.ForwardKey](t, commands); v != (ports.ForwardKey{ID: 1, Key: bound}) {
61+ t.Fatal(v)
62+ }
63+ select {
64+ case r := <-spawn:
65+ t.Fatalf("bind ran: %v", r)
66+ case <-time.After(30 * time.Millisecond):
67+ }
68+ // A new window takes focus: the inhibitor goes inactive, binds run.
69+ client <- ports.WindowMapped{ID: 2}
70+ if v := commandOf[ports.ShortcutsInhibitState](t, commands); v != (ports.ShortcutsInhibitState{Window: 1}) {
71+ t.Fatal(v)
72+ }
73+ input <- bound
74+ receive(t, spawn)
75+ // Idle inhibitors show in the state.
76+ client <- ports.IdleInhibit{Window: 2, Active: true}
77+ deadline := time.After(2 * time.Second)
78+ for {
79+ select {
80+ case st := <-state:
81+ for _, w := range st.Windows {
82+ if w.ID == 2 && w.IdleInhibit {
83+ return
84+ }
85+ }
86+ case <-deadline:
87+ t.Fatal("idle inhibit not in state")
88+ }
89+ }
90+}
@@ -0,0 +1,90 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/nefertty/internal/adapters/config"
9+ "github.com/bnema/nefertty/internal/core"
10+ "github.com/bnema/nefertty/internal/ports"
11+)
12+
13+// commandOf waits for the next command of type T, skipping others.
14+func commandOf[T ports.ClientCommand](t *testing.T, commands <-chan ports.ClientCommand) T {
15+ t.Helper()
16+ deadline := time.After(2 * time.Second)
17+ for {
18+ select {
19+ case c := <-commands:
20+ if v, ok := c.(T); ok {
21+ return v
22+ }
23+ case <-deadline:
24+ var zero T
25+ t.Fatalf("no %T", zero)
26+ return zero
27+ }
28+ }
29+}
30+
31+// A focused window inhibiting shortcuts gets bound keys; it stops when
32+// it loses focus, and binds run again.
33+func TestShortcutsInhibit(t *testing.T) {
34+ cfg := config.Defaults()
35+ input := make(chan ports.InputEvent, 4)
36+ client := make(chan ports.ClientEvent, 4)
37+ commands := make(chan ports.ClientCommand, 64)
38+ scenes := make(chan []ports.Scene, 1)
39+ spawn := make(chan ports.SpawnRequest, 4)
40+ state := make(chan ports.State, 1)
41+ output := make(chan ports.OutputEvent, 1)
42+ c, err := core.New(cfg, core.Channels{Input: input, Client: client, Output: output, Commands: commands, Scenes: scenes, Spawn: spawn, State: state})
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ ctx, cancel := context.WithCancel(context.Background())
47+ done := make(chan error, 1)
48+ go func() { done <- c.Run(ctx) }()
49+ defer func() { cancel(); receive(t, done) }()
50+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
51+ scene(t, scenes)
52+ client <- ports.WindowMapped{ID: 1}
53+ scene(t, scenes)
54+ client <- ports.ShortcutsInhibit{Window: 1, Active: true}
55+ if v := commandOf[ports.ShortcutsInhibitState](t, commands); v != (ports.ShortcutsInhibitState{Window: 1, Active: true}) {
56+ t.Fatal(v)
57+ }
58+ bound := ports.KeyEvent{Keysym: "Return", Mods: ports.ModSuper, Pressed: true}
59+ input <- bound
60+ if v := commandOf[ports.ForwardKey](t, commands); v != (ports.ForwardKey{ID: 1, Key: bound}) {
61+ t.Fatal(v)
62+ }
63+ select {
64+ case r := <-spawn:
65+ t.Fatalf("bind ran: %v", r)
66+ case <-time.After(30 * time.Millisecond):
67+ }
68+ // A new window takes focus: the inhibitor goes inactive, binds run.
69+ client <- ports.WindowMapped{ID: 2}
70+ if v := commandOf[ports.ShortcutsInhibitState](t, commands); v != (ports.ShortcutsInhibitState{Window: 1}) {
71+ t.Fatal(v)
72+ }
73+ input <- bound
74+ receive(t, spawn)
75+ // Idle inhibitors show in the state.
76+ client <- ports.IdleInhibit{Window: 2, Active: true}
77+ deadline := time.After(2 * time.Second)
78+ for {
79+ select {
80+ case st := <-state:
81+ for _, w := range st.Windows {
82+ if w.ID == 2 && w.IdleInhibit {
83+ return
84+ }
85+ }
86+ case <-deadline:
87+ t.Fatal("idle inhibit not in state")
88+ }
89+ }
90+}
M internal/core/state.go +1 -1
@@ -40,7 +40,7 @@ func (c *Core) state() ports.State {
4040 n := indexOf(m.Workspaces, w) + 1
4141 for _, id := range w.windows() {
4242 info := c.clients[id]
43- st.Windows = append(st.Windows, ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: w == cur})
43+ st.Windows = append(st.Windows, ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: w == cur, IdleInhibit: c.idle[id]})
4444 }
4545 }
4646 }
@@ -40,7 +40,7 @@ func (c *Core) state() ports.State {
40 n := indexOf(m.Workspaces, w) + 140 n := indexOf(m.Workspaces, w) + 1
41 for _, id := range w.windows() {41 for _, id := range w.windows() {
42 info := c.clients[id]42 info := c.clients[id]
43- st.Windows = append(st.Windows, ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: w == cur})43+ st.Windows = append(st.Windows, ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: w == cur, IdleInhibit: c.idle[id]})
44 }44 }
45 }45 }
46 }46 }
M internal/ports/messages.go +30 -0
@@ -34,6 +34,25 @@ type WindowUnmapped struct{ ID WindowID }
3434
3535 func (WindowUnmapped) clientEvent() {}
3636
37+// ShortcutsInhibit carries wayland → core that a window asks for (Active)
38+// or stops asking for all keys, compositor binds included, while it has
39+// keyboard focus (zwp_keyboard_shortcuts_inhibit_v1).
40+type ShortcutsInhibit struct {
41+ Window WindowID
42+ Active bool
43+}
44+
45+func (ShortcutsInhibit) clientEvent() {}
46+
47+// IdleInhibit carries wayland → core that a window keeps (Active) or
48+// stops keeping the session from going idle (zwp_idle_inhibit_v1).
49+type IdleInhibit struct {
50+ Window WindowID
51+ Active bool
52+}
53+
54+func (IdleInhibit) clientEvent() {}
55+
3756 // WindowFullscreenRequest carries wayland → core fullscreen requests.
3857 type WindowFullscreenRequest struct {
3958 ID WindowID
@@ -371,6 +390,15 @@ type ClientCommand interface{ clientCommand() }
371390
372391 // ConfigureWindow carries core → wayland geometry and state. Output is the
373392 // connector showing the window, empty while it is hidden.
393+// ShortcutsInhibitState carries core → wayland whether a window's
394+// shortcuts inhibitor is in effect (it has keyboard focus).
395+type ShortcutsInhibitState struct {
396+ Window WindowID
397+ Active bool
398+}
399+
400+func (ShortcutsInhibitState) clientCommand() {}
401+
374402 type ConfigureWindow struct {
375403 ID WindowID
376404 Width, Height int
@@ -769,4 +797,6 @@ type WindowState struct {
769797 // Visible means on the workspace on screen (it may be behind a
770798 // fullscreen window).
771799 Visible bool
800+ // IdleInhibit is set while the window keeps the session from idling.
801+ IdleInhibit bool `json:",omitempty"`
772802 }
@@ -34,6 +34,25 @@ type WindowUnmapped struct{ ID WindowID }
34 34
35 func (WindowUnmapped) clientEvent() {}35 func (WindowUnmapped) clientEvent() {}
36 36
37+// ShortcutsInhibit carries wayland → core that a window asks for (Active)
38+// or stops asking for all keys, compositor binds included, while it has
39+// keyboard focus (zwp_keyboard_shortcuts_inhibit_v1).
40+type ShortcutsInhibit struct {
41+ Window WindowID
42+ Active bool
43+}
44+
45+func (ShortcutsInhibit) clientEvent() {}
46+
47+// IdleInhibit carries wayland → core that a window keeps (Active) or
48+// stops keeping the session from going idle (zwp_idle_inhibit_v1).
49+type IdleInhibit struct {
50+ Window WindowID
51+ Active bool
52+}
53+
54+func (IdleInhibit) clientEvent() {}
55+
37 // WindowFullscreenRequest carries wayland → core fullscreen requests.56 // WindowFullscreenRequest carries wayland → core fullscreen requests.
38 type WindowFullscreenRequest struct {57 type WindowFullscreenRequest struct {
39 ID WindowID58 ID WindowID
@@ -371,6 +390,15 @@ type ClientCommand interface{ clientCommand() }
371 390
372 // ConfigureWindow carries core → wayland geometry and state. Output is the391 // ConfigureWindow carries core → wayland geometry and state. Output is the
373 // connector showing the window, empty while it is hidden.392 // connector showing the window, empty while it is hidden.
393+// ShortcutsInhibitState carries core → wayland whether a window's
394+// shortcuts inhibitor is in effect (it has keyboard focus).
395+type ShortcutsInhibitState struct {
396+ Window WindowID
397+ Active bool
398+}
399+
400+func (ShortcutsInhibitState) clientCommand() {}
401+
374 type ConfigureWindow struct {402 type ConfigureWindow struct {
375 ID WindowID403 ID WindowID
376 Width, Height int404 Width, Height int
@@ -769,4 +797,6 @@ type WindowState struct {
769 // Visible means on the workspace on screen (it may be behind a797 // Visible means on the workspace on screen (it may be behind a
770 // fullscreen window).798 // fullscreen window).
771 Visible bool799 Visible bool
800+ // IdleInhibit is set while the window keeps the session from idling.
801+ IdleInhibit bool `json:",omitempty"`
772 }802 }

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 26

Tags 10

↑↓ moveenter openesc close