detached 0e37717

Merge pull request #2 from bnema/feat/secret-bytes

feat(state): add UTF8Into for string-free secret key input
commit
0e37717000dd059ed53a35fab02996f9e8277ce6
author
bnema · · 2026-09-30 19:50 UTC
committer
GitHub · · 2026-09-30 19:50 UTC
merge of
e79265d + aad1ba3 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.1.0
3 files · +221 -0
M README.md +2 -0
@@ -17,4 +17,6 @@ text, err := state.UTF8(xkbcommon.WaylandKeycode(evdevKeycode))
1717
1818 Use `NewKeymapFD(fd, size)` for Wayland `xkb_v1` keymaps; it copies exactly `size` bytes with `pread` (at most 16 MiB), fails on a short read, and accepts an optional trailing NUL. **The caller retains and must close the FD**, on success and failure. `UpdateMask` accepts Wayland modifier masks and group indices. `ComposeState.Feed` and `Status` distinguish composing, composed, cancelled, and ignored keysyms; use `UTF8` only for a composed result. Objects retain their native parents; close each object, in any order. Do not copy value structs containing a mutex.
1919
20+`State.UTF8Into` and `ComposeState.UTF8Into` write into a caller-owned buffer of 1..`MaxUTF8Buffer` bytes and create no Go string. On `io.ErrShortBuffer` the whole buffer is wiped and n+1 bytes are needed; the caller wipes the buffer after use.
21+
2022 Pinned upstream headers and licenses are in `upstream/`; `upstream/METADATA` and `SHA256SUMS` identify their exact source. Run `GOWORK=off go generate ./...` to regenerate `raw/generated.go`; consumers do not run generation. The generator checks the selected ABI declarations against the headers. `github.com/bnema/purego v0.13.0-bnema.1` matches purego-vulkan and supports `Dlopen`, `Dlsym`, and `RegisterFunc` with cgo disabled.
@@ -17,4 +17,6 @@ text, err := state.UTF8(xkbcommon.WaylandKeycode(evdevKeycode))
17 17
18 Use `NewKeymapFD(fd, size)` for Wayland `xkb_v1` keymaps; it copies exactly `size` bytes with `pread` (at most 16 MiB), fails on a short read, and accepts an optional trailing NUL. **The caller retains and must close the FD**, on success and failure. `UpdateMask` accepts Wayland modifier masks and group indices. `ComposeState.Feed` and `Status` distinguish composing, composed, cancelled, and ignored keysyms; use `UTF8` only for a composed result. Objects retain their native parents; close each object, in any order. Do not copy value structs containing a mutex.18 Use `NewKeymapFD(fd, size)` for Wayland `xkb_v1` keymaps; it copies exactly `size` bytes with `pread` (at most 16 MiB), fails on a short read, and accepts an optional trailing NUL. **The caller retains and must close the FD**, on success and failure. `UpdateMask` accepts Wayland modifier masks and group indices. `ComposeState.Feed` and `Status` distinguish composing, composed, cancelled, and ignored keysyms; use `UTF8` only for a composed result. Objects retain their native parents; close each object, in any order. Do not copy value structs containing a mutex.
19 19
20+`State.UTF8Into` and `ComposeState.UTF8Into` write into a caller-owned buffer of 1..`MaxUTF8Buffer` bytes and create no Go string. On `io.ErrShortBuffer` the whole buffer is wiped and n+1 bytes are needed; the caller wipes the buffer after use.
21+
20 Pinned upstream headers and licenses are in `upstream/`; `upstream/METADATA` and `SHA256SUMS` identify their exact source. Run `GOWORK=off go generate ./...` to regenerate `raw/generated.go`; consumers do not run generation. The generator checks the selected ABI declarations against the headers. `github.com/bnema/purego v0.13.0-bnema.1` matches purego-vulkan and supports `Dlopen`, `Dlsym`, and `RegisterFunc` with cgo disabled.22 Pinned upstream headers and licenses are in `upstream/`; `upstream/METADATA` and `SHA256SUMS` identify their exact source. Run `GOWORK=off go generate ./...` to regenerate `raw/generated.go`; consumers do not run generation. The generator checks the selected ABI declarations against the headers. `github.com/bnema/purego v0.13.0-bnema.1` matches purego-vulkan and supports `Dlopen`, `Dlsym`, and `RegisterFunc` with cgo disabled.
A utf8_into_test.go +151 -0
@@ -0,0 +1,151 @@
1+package xkbcommon
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "io"
7+ "testing"
8+
9+ "github.com/bnema/purego-xkbcommon/raw"
10+)
11+
12+func TestStateUTF8IntoBoundsAndLifetime(t *testing.T) {
13+ c := live(t)
14+ s := state(t, rules(t, c, "de"))
15+ var scratch [MaxUTF8Buffer]byte
16+ n, err := s.UTF8Into(WaylandKeycode(16), scratch[:])
17+ if err != nil || !bytes.Equal(scratch[:n], []byte{'q'}) {
18+ t.Fatalf("ASCII length=%d err=%v", n, err)
19+ }
20+ mod, err := rules(t, c, "de").ModIndex("Mod5")
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ if _, err := s.UpdateMask(1<<mod, 0, 0, 0, 0, 0); err != nil {
25+ t.Fatal(err)
26+ }
27+ n, err = s.UTF8Into(WaylandKeycode(18), scratch[:])
28+ if err != nil || !bytes.Equal(scratch[:n], []byte{0xe2, 0x82, 0xac}) {
29+ t.Fatalf("multibyte length=%d err=%v", n, err)
30+ }
31+ short := []byte{0xff, 0xff, 0xff}
32+ n, err = s.UTF8Into(WaylandKeycode(18), short)
33+ if !errors.Is(err, io.ErrShortBuffer) || n != 3 || !bytes.Equal(short, make([]byte, len(short))) {
34+ t.Fatalf("short: n=%d err=%v", n, err)
35+ }
36+ for _, length := range []int{0, MaxUTF8Buffer + 1} {
37+ buf := bytes.Repeat([]byte{0xff}, length)
38+ if _, err := s.UTF8Into(WaylandKeycode(18), buf); !errors.Is(err, ErrUTF8Buffer) {
39+ t.Fatal(err)
40+ }
41+ if !bytes.Equal(buf, make([]byte, length)) {
42+ t.Fatal("invalid buffer not cleared")
43+ }
44+ }
45+ s.Close()
46+ for i := range scratch {
47+ scratch[i] = 0xff
48+ }
49+ if _, err := s.UTF8Into(WaylandKeycode(18), scratch[:]); !errors.Is(err, ErrClosed) {
50+ t.Fatal(err)
51+ }
52+ if !bytes.Equal(scratch[:], make([]byte, len(scratch))) {
53+ t.Fatal("closed handle left old bytes")
54+ }
55+}
56+
57+func TestComposeUTF8Into(t *testing.T) {
58+ c := live(t)
59+ table, err := c.NewComposeTable("en_US.UTF-8")
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ defer table.Close()
64+ compose, err := table.NewState()
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ defer compose.Close()
69+ for _, sym := range []uint32{raw.XKB_KEY_dead_circumflex, raw.XKB_KEY_e} {
70+ if _, err := compose.Feed(sym); err != nil {
71+ t.Fatal(err)
72+ }
73+ }
74+ var buf [MaxUTF8Buffer]byte
75+ if n, err := compose.UTF8Into(buf[:]); err != nil || !bytes.Equal(buf[:n], []byte{0xc3, 0xaa}) {
76+ t.Fatalf("compose length=%d err=%v", n, err)
77+ }
78+ if n, err := compose.UTF8Into(buf[:2]); !errors.Is(err, io.ErrShortBuffer) || n != 2 {
79+ t.Fatalf("compose short length=%d err=%v", n, err)
80+ }
81+ if buf[0] != 0 || buf[1] != 0 {
82+ t.Fatal("short compose retained bytes")
83+ }
84+ if err := compose.Reset(); err != nil {
85+ t.Fatal(err)
86+ }
87+ if n, err := compose.UTF8Into(buf[:]); err != nil || n != 0 {
88+ t.Fatalf("reset length=%d err=%v", n, err)
89+ }
90+}
91+
92+func TestUTF8IntoBoundedAllocations(t *testing.T) {
93+ s := state(t, rules(t, live(t), "us"))
94+ var buf [MaxUTF8Buffer]byte
95+ if n, err := s.UTF8Into(WaylandKeycode(16), buf[:]); err != nil || n != 1 {
96+ t.Fatal(n, err)
97+ }
98+ allocs := testing.AllocsPerRun(100, func() {
99+ if _, err := s.UTF8Into(WaylandKeycode(16), buf[:]); err != nil {
100+ t.Fatal(err)
101+ }
102+ clear(buf[:])
103+ })
104+ // Purego's native dispatch may allocate fixed ABI scratch; the wrapper
105+ // must not allocate result-sized buffers or string payloads.
106+ // Current purego dispatch plus scoped pinning costs six fixed allocations.
107+ if allocs > 6 {
108+ t.Fatalf("UTF8Into allocations=%v", allocs)
109+ }
110+}
111+
112+func TestUTF8IntoNilAndClosedReceiversClearScratch(t *testing.T) {
113+ var nilState *State
114+ var nilCompose *ComposeState
115+ buf := []byte{0xff, 0xff, 0xff}
116+ if n, err := nilState.UTF8Into(WaylandKeycode(16), buf); n != 0 || !errors.Is(err, ErrClosed) {
117+ t.Fatal(n, err)
118+ }
119+ if !bytes.Equal(buf, []byte{0, 0, 0}) {
120+ t.Fatal("nil state retained bytes")
121+ }
122+ for i := range buf {
123+ buf[i] = 0xff
124+ }
125+ if n, err := nilCompose.UTF8Into(buf); n != 0 || !errors.Is(err, ErrClosed) {
126+ t.Fatal(n, err)
127+ }
128+ if !bytes.Equal(buf, []byte{0, 0, 0}) {
129+ t.Fatal("nil compose retained bytes")
130+ }
131+ c := live(t)
132+ table, err := c.NewComposeTable("en_US.UTF-8")
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ defer table.Close()
137+ compose, err := table.NewState()
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ compose.Close()
142+ for i := range buf {
143+ buf[i] = 0xff
144+ }
145+ if n, err := compose.UTF8Into(buf); n != 0 || !errors.Is(err, ErrClosed) {
146+ t.Fatal(n, err)
147+ }
148+ if !bytes.Equal(buf, []byte{0, 0, 0}) {
149+ t.Fatal("closed compose retained bytes")
150+ }
151+}
@@ -0,0 +1,151 @@
1+package xkbcommon
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "io"
7+ "testing"
8+
9+ "github.com/bnema/purego-xkbcommon/raw"
10+)
11+
12+func TestStateUTF8IntoBoundsAndLifetime(t *testing.T) {
13+ c := live(t)
14+ s := state(t, rules(t, c, "de"))
15+ var scratch [MaxUTF8Buffer]byte
16+ n, err := s.UTF8Into(WaylandKeycode(16), scratch[:])
17+ if err != nil || !bytes.Equal(scratch[:n], []byte{'q'}) {
18+ t.Fatalf("ASCII length=%d err=%v", n, err)
19+ }
20+ mod, err := rules(t, c, "de").ModIndex("Mod5")
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ if _, err := s.UpdateMask(1<<mod, 0, 0, 0, 0, 0); err != nil {
25+ t.Fatal(err)
26+ }
27+ n, err = s.UTF8Into(WaylandKeycode(18), scratch[:])
28+ if err != nil || !bytes.Equal(scratch[:n], []byte{0xe2, 0x82, 0xac}) {
29+ t.Fatalf("multibyte length=%d err=%v", n, err)
30+ }
31+ short := []byte{0xff, 0xff, 0xff}
32+ n, err = s.UTF8Into(WaylandKeycode(18), short)
33+ if !errors.Is(err, io.ErrShortBuffer) || n != 3 || !bytes.Equal(short, make([]byte, len(short))) {
34+ t.Fatalf("short: n=%d err=%v", n, err)
35+ }
36+ for _, length := range []int{0, MaxUTF8Buffer + 1} {
37+ buf := bytes.Repeat([]byte{0xff}, length)
38+ if _, err := s.UTF8Into(WaylandKeycode(18), buf); !errors.Is(err, ErrUTF8Buffer) {
39+ t.Fatal(err)
40+ }
41+ if !bytes.Equal(buf, make([]byte, length)) {
42+ t.Fatal("invalid buffer not cleared")
43+ }
44+ }
45+ s.Close()
46+ for i := range scratch {
47+ scratch[i] = 0xff
48+ }
49+ if _, err := s.UTF8Into(WaylandKeycode(18), scratch[:]); !errors.Is(err, ErrClosed) {
50+ t.Fatal(err)
51+ }
52+ if !bytes.Equal(scratch[:], make([]byte, len(scratch))) {
53+ t.Fatal("closed handle left old bytes")
54+ }
55+}
56+
57+func TestComposeUTF8Into(t *testing.T) {
58+ c := live(t)
59+ table, err := c.NewComposeTable("en_US.UTF-8")
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ defer table.Close()
64+ compose, err := table.NewState()
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ defer compose.Close()
69+ for _, sym := range []uint32{raw.XKB_KEY_dead_circumflex, raw.XKB_KEY_e} {
70+ if _, err := compose.Feed(sym); err != nil {
71+ t.Fatal(err)
72+ }
73+ }
74+ var buf [MaxUTF8Buffer]byte
75+ if n, err := compose.UTF8Into(buf[:]); err != nil || !bytes.Equal(buf[:n], []byte{0xc3, 0xaa}) {
76+ t.Fatalf("compose length=%d err=%v", n, err)
77+ }
78+ if n, err := compose.UTF8Into(buf[:2]); !errors.Is(err, io.ErrShortBuffer) || n != 2 {
79+ t.Fatalf("compose short length=%d err=%v", n, err)
80+ }
81+ if buf[0] != 0 || buf[1] != 0 {
82+ t.Fatal("short compose retained bytes")
83+ }
84+ if err := compose.Reset(); err != nil {
85+ t.Fatal(err)
86+ }
87+ if n, err := compose.UTF8Into(buf[:]); err != nil || n != 0 {
88+ t.Fatalf("reset length=%d err=%v", n, err)
89+ }
90+}
91+
92+func TestUTF8IntoBoundedAllocations(t *testing.T) {
93+ s := state(t, rules(t, live(t), "us"))
94+ var buf [MaxUTF8Buffer]byte
95+ if n, err := s.UTF8Into(WaylandKeycode(16), buf[:]); err != nil || n != 1 {
96+ t.Fatal(n, err)
97+ }
98+ allocs := testing.AllocsPerRun(100, func() {
99+ if _, err := s.UTF8Into(WaylandKeycode(16), buf[:]); err != nil {
100+ t.Fatal(err)
101+ }
102+ clear(buf[:])
103+ })
104+ // Purego's native dispatch may allocate fixed ABI scratch; the wrapper
105+ // must not allocate result-sized buffers or string payloads.
106+ // Current purego dispatch plus scoped pinning costs six fixed allocations.
107+ if allocs > 6 {
108+ t.Fatalf("UTF8Into allocations=%v", allocs)
109+ }
110+}
111+
112+func TestUTF8IntoNilAndClosedReceiversClearScratch(t *testing.T) {
113+ var nilState *State
114+ var nilCompose *ComposeState
115+ buf := []byte{0xff, 0xff, 0xff}
116+ if n, err := nilState.UTF8Into(WaylandKeycode(16), buf); n != 0 || !errors.Is(err, ErrClosed) {
117+ t.Fatal(n, err)
118+ }
119+ if !bytes.Equal(buf, []byte{0, 0, 0}) {
120+ t.Fatal("nil state retained bytes")
121+ }
122+ for i := range buf {
123+ buf[i] = 0xff
124+ }
125+ if n, err := nilCompose.UTF8Into(buf); n != 0 || !errors.Is(err, ErrClosed) {
126+ t.Fatal(n, err)
127+ }
128+ if !bytes.Equal(buf, []byte{0, 0, 0}) {
129+ t.Fatal("nil compose retained bytes")
130+ }
131+ c := live(t)
132+ table, err := c.NewComposeTable("en_US.UTF-8")
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ defer table.Close()
137+ compose, err := table.NewState()
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ compose.Close()
142+ for i := range buf {
143+ buf[i] = 0xff
144+ }
145+ if n, err := compose.UTF8Into(buf); n != 0 || !errors.Is(err, ErrClosed) {
146+ t.Fatal(n, err)
147+ }
148+ if !bytes.Equal(buf, []byte{0, 0, 0}) {
149+ t.Fatal("closed compose retained bytes")
150+ }
151+}
M xkbcommon.go +68 -0
@@ -5,6 +5,7 @@ package xkbcommon
55 import (
66 "errors"
77 "fmt"
8+ "io"
89 "runtime"
910 "strings"
1011 "sync"
@@ -332,6 +333,57 @@ func sizedUTF8(call func(uintptr, uintptr) int32) (string, error) {
332333 }
333334 return "", errors.New("xkbcommon: UTF-8 result changed during sizing")
334335 }
336+
337+// MaxUTF8Buffer is the byte API's maximum scratch length, including the
338+// native trailing NUL. Thus a full result can contain at most 511 payload bytes.
339+const MaxUTF8Buffer = 512
340+
341+// ErrUTF8Buffer reports a destination buffer whose length is outside
342+// 1..MaxUTF8Buffer.
343+var ErrUTF8Buffer = fmt.Errorf("xkbcommon: UTF-8 buffer length must be 1..%d", MaxUTF8Buffer)
344+
345+// utf8Into never creates a string or keeps the caller's storage. Native writes
346+// are synchronous; the backing array is pinned only for the duration of the call.
347+// Short buffers are wiped rather than exposing truncated UTF-8 or credentials.
348+// Callers clear dst before any early return.
349+func utf8Into(dst []byte, call func(uintptr, uintptr) int32) (int, error) {
350+ if len(dst) < 1 || len(dst) > MaxUTF8Buffer {
351+ return 0, ErrUTF8Buffer
352+ }
353+ var pin runtime.Pinner
354+ pin.Pin(&dst[0])
355+ defer pin.Unpin()
356+ n := call(ptr(dst), uintptr(len(dst)))
357+ runtime.KeepAlive(dst)
358+ if n < 0 {
359+ clear(dst)
360+ return 0, errors.New("xkbcommon: UTF-8 conversion failed")
361+ }
362+ if int(n) >= len(dst) {
363+ clear(dst)
364+ return int(n), io.ErrShortBuffer
365+ }
366+ clear(dst[n:])
367+ return int(n), nil
368+}
369+
370+// UTF8Into writes into caller-owned mutable storage without constructing a
371+// string. n excludes NUL; on io.ErrShortBuffer n is the required payload size,
372+// all of dst is cleared, and the caller needs n+1 bytes. dst must have length
373+// 1..MaxUTF8Buffer. No storage is retained; the caller owns wiping after use.
374+func (s *State) UTF8Into(keycode uint32, dst []byte) (int, error) {
375+ clear(dst)
376+ if s == nil {
377+ return 0, ErrClosed
378+ }
379+ s.mu.Lock()
380+ defer s.mu.Unlock()
381+ if s.h == 0 {
382+ return 0, ErrClosed
383+ }
384+ return utf8Into(dst, func(p, n uintptr) int32 { return loader.f.Xkb_state_key_get_utf8(s.h, keycode, p, n) })
385+}
386+
335387 func (s *State) UTF8(keycode uint32) (string, error) {
336388 if s == nil {
337389 return "", ErrClosed
@@ -464,6 +516,22 @@ func (s *ComposeState) Status() (uint32, error) {
464516 }
465517 return loader.f.Xkb_compose_state_get_status(s.h), nil
466518 }
519+
520+// UTF8Into is the compose counterpart of State.UTF8Into, with identical
521+// bounds, ownership, NUL and short-buffer semantics. It does not reset compose.
522+func (s *ComposeState) UTF8Into(dst []byte) (int, error) {
523+ clear(dst)
524+ if s == nil {
525+ return 0, ErrClosed
526+ }
527+ s.mu.Lock()
528+ defer s.mu.Unlock()
529+ if s.h == 0 {
530+ return 0, ErrClosed
531+ }
532+ return utf8Into(dst, func(p, n uintptr) int32 { return loader.f.Xkb_compose_state_get_utf8(s.h, p, n) })
533+}
534+
467535 func (s *ComposeState) UTF8() (string, error) {
468536 if s == nil {
469537 return "", ErrClosed
@@ -5,6 +5,7 @@ package xkbcommon
5 import (5 import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8+ "io"
8 "runtime"9 "runtime"
9 "strings"10 "strings"
10 "sync"11 "sync"
@@ -332,6 +333,57 @@ func sizedUTF8(call func(uintptr, uintptr) int32) (string, error) {
332 }333 }
333 return "", errors.New("xkbcommon: UTF-8 result changed during sizing")334 return "", errors.New("xkbcommon: UTF-8 result changed during sizing")
334 }335 }
336+
337+// MaxUTF8Buffer is the byte API's maximum scratch length, including the
338+// native trailing NUL. Thus a full result can contain at most 511 payload bytes.
339+const MaxUTF8Buffer = 512
340+
341+// ErrUTF8Buffer reports a destination buffer whose length is outside
342+// 1..MaxUTF8Buffer.
343+var ErrUTF8Buffer = fmt.Errorf("xkbcommon: UTF-8 buffer length must be 1..%d", MaxUTF8Buffer)
344+
345+// utf8Into never creates a string or keeps the caller's storage. Native writes
346+// are synchronous; the backing array is pinned only for the duration of the call.
347+// Short buffers are wiped rather than exposing truncated UTF-8 or credentials.
348+// Callers clear dst before any early return.
349+func utf8Into(dst []byte, call func(uintptr, uintptr) int32) (int, error) {
350+ if len(dst) < 1 || len(dst) > MaxUTF8Buffer {
351+ return 0, ErrUTF8Buffer
352+ }
353+ var pin runtime.Pinner
354+ pin.Pin(&dst[0])
355+ defer pin.Unpin()
356+ n := call(ptr(dst), uintptr(len(dst)))
357+ runtime.KeepAlive(dst)
358+ if n < 0 {
359+ clear(dst)
360+ return 0, errors.New("xkbcommon: UTF-8 conversion failed")
361+ }
362+ if int(n) >= len(dst) {
363+ clear(dst)
364+ return int(n), io.ErrShortBuffer
365+ }
366+ clear(dst[n:])
367+ return int(n), nil
368+}
369+
370+// UTF8Into writes into caller-owned mutable storage without constructing a
371+// string. n excludes NUL; on io.ErrShortBuffer n is the required payload size,
372+// all of dst is cleared, and the caller needs n+1 bytes. dst must have length
373+// 1..MaxUTF8Buffer. No storage is retained; the caller owns wiping after use.
374+func (s *State) UTF8Into(keycode uint32, dst []byte) (int, error) {
375+ clear(dst)
376+ if s == nil {
377+ return 0, ErrClosed
378+ }
379+ s.mu.Lock()
380+ defer s.mu.Unlock()
381+ if s.h == 0 {
382+ return 0, ErrClosed
383+ }
384+ return utf8Into(dst, func(p, n uintptr) int32 { return loader.f.Xkb_state_key_get_utf8(s.h, keycode, p, n) })
385+}
386+
335 func (s *State) UTF8(keycode uint32) (string, error) {387 func (s *State) UTF8(keycode uint32) (string, error) {
336 if s == nil {388 if s == nil {
337 return "", ErrClosed389 return "", ErrClosed
@@ -464,6 +516,22 @@ func (s *ComposeState) Status() (uint32, error) {
464 }516 }
465 return loader.f.Xkb_compose_state_get_status(s.h), nil517 return loader.f.Xkb_compose_state_get_status(s.h), nil
466 }518 }
519+
520+// UTF8Into is the compose counterpart of State.UTF8Into, with identical
521+// bounds, ownership, NUL and short-buffer semantics. It does not reset compose.
522+func (s *ComposeState) UTF8Into(dst []byte) (int, error) {
523+ clear(dst)
524+ if s == nil {
525+ return 0, ErrClosed
526+ }
527+ s.mu.Lock()
528+ defer s.mu.Unlock()
529+ if s.h == 0 {
530+ return 0, ErrClosed
531+ }
532+ return utf8Into(dst, func(p, n uintptr) int32 { return loader.f.Xkb_compose_state_get_utf8(s.h, p, n) })
533+}
534+
467 func (s *ComposeState) UTF8() (string, error) {535 func (s *ComposeState) UTF8() (string, error) {
468 if s == nil {536 if s == nil {
469 return "", ErrClosed537 return "", ErrClosed

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 1

Tags 2

↑↓ moveenter openesc close