v0.3.0 6800c6a

ime.go RawHistoryFolder

1//go:build linux
2
3package wayland
4
5import (
6	"errors"
7	"math"
8	"unicode/utf8"
9
10	"github.com/bnema/nefergui/internal/edit"
11	"github.com/bnema/wlturbo"
12	"github.com/bnema/wlturbo/protocol/core"
13	"github.com/bnema/wlturbo/protocol/textinput"
14	"github.com/bnema/wlturbo/wl"
15)
16
17// IMEEvent is posted from the Wayland reader to the session loop. Only a done
18// event produces an edit.IMEBatch; intermediate protocol events are buffered.
19type IMEEvent struct {
20	Kind          string
21	Surface       uint32
22	Text          string
23	Begin, End    int32
24	Before, After uint32
25	Serial        uint32
26}
27
28// IME is loop-owned. On errors in the edit.IME interface, Err records the
29// first protocol send failure; the session owner should inspect it each frame.
30type IME struct {
31	manager                      *textinput.TextInputManagerV3
32	input                        *textinput.TextInputV3
33	surface                      uint32
34	post                         func(IMEEvent)
35	batch                        edit.IMEBatch
36	commits                      uint32
37	entered, enabled             bool
38	requested, synced            bool
39	pendingSurround              string
40	hasSurround                  bool
41	pendingCursor, pendingAnchor int
42	pendingContent               bool
43	password, multiline          bool
44	pendingRect                  bool
45	rect                         [4]int32
46	Err                          error
47}
48
49var _ edit.IME = (*IME)(nil)
50
51// NewIME returns nil if text-input-v3 is not announced; keyboard remains usable.
52func NewIME(d *wl.Display, seat *core.Seat, surface *core.Surface, post func(IMEEvent)) (*IME, error) {
53	if d == nil || seat == nil || surface == nil {
54		return nil, nil
55	}
56	manager := textinput.NewTextInputManagerV3(d.Context())
57	if _, err := d.Registry().BindNegotiated(textinput.TextInputManagerV3Interface, 2, manager); err != nil {
58		d.Context().Unregister(manager)
59		if errors.Is(err, wlturbo.ErrGlobalNotFound) {
60			return nil, nil
61		}
62		return nil, err
63	}
64	input, err := manager.GetTextInput(seat)
65	if err != nil {
66		_ = manager.Destroy()
67		return nil, err
68	}
69	m := &IME{manager: manager, input: input, surface: surface.ID(), post: post}
70	input.OnEnter(func(id uint32) { post(IMEEvent{Kind: "enter", Surface: id}) })
71	input.OnLeave(func(id uint32) { post(IMEEvent{Kind: "leave", Surface: id}) })
72	input.OnPreeditString(func(s string, b, e int32) { post(IMEEvent{Kind: "preedit", Text: s, Begin: b, End: e}) })
73	input.OnCommitString(func(s string) { post(IMEEvent{Kind: "commit", Text: s}) })
74	input.OnDeleteSurroundingText(func(b, a uint32) { post(IMEEvent{Kind: "delete", Before: b, After: a}) })
75	input.OnDone(func(serial uint32) { post(IMEEvent{Kind: "done", Serial: serial}) })
76	return m, nil
77}
78func (m *IME) send(err error) {
79	if err != nil && m.Err == nil {
80		m.Err = err
81	}
82}
83func (m *IME) commit() {
84	if m == nil || m.input == nil {
85		return
86	}
87	if err := m.input.Commit(); err != nil {
88		m.send(err)
89	} else {
90		m.commits++
91	}
92}
93func (m *IME) Enable() {
94	if m == nil {
95		return
96	}
97	m.requested = true
98	if !m.entered || m.enabled {
99		return
100	}
101	m.send(m.input.Enable())
102	if m.Err == nil {
103		m.enabled = true
104		m.synced = true
105		// Initial state is double-buffered with enable in one commit.
106		if m.pendingContent {
107			m.pendingContent = false
108			m.send(m.input.SetContentType(m.contentHint(), m.contentPurpose()))
109		}
110		if m.hasSurround {
111			m.hasSurround = false
112			m.send(m.input.SetSurroundingText(m.pendingSurround, int32(m.pendingCursor), int32(m.pendingAnchor)))
113		}
114		if m.pendingRect {
115			m.pendingRect = false
116			m.send(m.input.SetCursorRectangle(m.rect[0], m.rect[1], m.rect[2], m.rect[3]))
117		}
118		if m.Err == nil {
119			m.commit()
120		}
121	}
122}
123func (m *IME) Disable() {
124	if m == nil {
125		return
126	}
127	m.requested = false
128	if !m.enabled {
129		return
130	}
131	m.send(m.input.Disable())
132	m.enabled = false
133	m.synced = true
134	m.commit()
135	m.batch = edit.IMEBatch{}
136}
137func (m *IME) Surrounding(text string, cursor, anchor int) {
138	if m == nil {
139		return
140	}
141	text, cursor, anchor = surroundingWindow(text, cursor, anchor)
142	if !m.enabled || !m.synced {
143		m.pendingSurround, m.pendingCursor, m.pendingAnchor = text, cursor, anchor
144		m.hasSurround = true
145		return
146	}
147	m.send(m.input.SetSurroundingText(text, int32(cursor), int32(anchor)))
148	m.commit()
149}
150func (m *IME) CursorRect(x, y, w, h float64) {
151	if m == nil {
152		return
153	}
154	if !validRect(x, y, w, h) {
155		return
156	}
157	m.rect = [4]int32{int32(math.Round(x)), int32(math.Round(y)), int32(math.Round(w)), int32(math.Round(h))}
158	if !m.enabled || !m.synced {
159		m.pendingRect = true
160		return
161	}
162	m.send(m.input.SetCursorRectangle(m.rect[0], m.rect[1], m.rect[2], m.rect[3]))
163	m.commit()
164}
165func validRect(x, y, w, h float64) bool {
166	for _, v := range []float64{x, y, w, h} {
167		if math.IsNaN(v) || math.IsInf(v, 0) || v < -math.MaxInt32 || v > math.MaxInt32 {
168			return false
169		}
170	}
171	return w >= 0 && h >= 0
172}
173func (m *IME) ContentType(password, multiline bool) {
174	if m == nil {
175		return
176	}
177	if !m.enabled || !m.synced {
178		m.pendingContent = true
179		m.password, m.multiline = password, multiline
180		return
181	}
182	m.password, m.multiline = password, multiline
183	m.send(m.input.SetContentType(m.contentHint(), m.contentPurpose()))
184	m.commit()
185}
186func (m *IME) contentHint() uint32 {
187	hint := uint32(textinput.CONTENT_HINT_NONE)
188	if m.password {
189		return textinput.CONTENT_HINT_SENSITIVE_DATA | textinput.CONTENT_HINT_HIDDEN_TEXT
190	}
191	if m.multiline {
192		return textinput.CONTENT_HINT_MULTILINE
193	}
194	return hint
195}
196func (m *IME) contentPurpose() uint32 {
197	if m.password {
198		return textinput.CONTENT_PURPOSE_PASSWORD
199	}
200	return textinput.CONTENT_PURPOSE_NORMAL
201}
202
203// surroundingWindow keeps a UTF-8 aligned 4000-byte window including the
204// cursor and as much of the selection as possible, centered near the cursor.
205func surroundingWindow(s string, cursor, anchor int) (string, int, int) {
206	if !utf8.ValidString(s) {
207		s = ""
208	}
209	cursor = max(0, min(cursor, len(s)))
210	anchor = max(0, min(anchor, len(s)))
211	for cursor > 0 && cursor < len(s) && !utf8.RuneStart(s[cursor]) {
212		cursor--
213	}
214	for anchor > 0 && anchor < len(s) && !utf8.RuneStart(s[anchor]) {
215		anchor--
216	}
217	if len(s) <= 4000 {
218		return s, cursor, anchor
219	}
220	start := max(0, cursor-2000)
221	if anchor < cursor && cursor-anchor <= 4000 {
222		start = min(start, anchor)
223	}
224	if anchor > cursor && anchor-cursor <= 4000 {
225		start = max(start, anchor-4000)
226	}
227	start = min(start, len(s)-4000)
228	for start < len(s) && !utf8.RuneStart(s[start]) {
229		start++
230	}
231	end := min(len(s), start+4000)
232	for end > start && end < len(s) && !utf8.RuneStart(s[end]) {
233		end--
234	}
235	cursor -= start
236	anchor = max(0, min(anchor-start, end-start))
237	return s[start:end], cursor, anchor
238}
239
240// ApplyIME buffers reader events until done. The serial is the compositor's
241// count of client commit requests, not a keyboard serial. Per text-input-v3,
242// even a mismatched serial must apply the edit batch as normal; it only
243// prohibits changing protocol state until a matching done is received.
244func (m *IME) ApplyIME(ev IMEEvent) (edit.IMEBatch, bool) {
245	if m == nil {
246		return edit.IMEBatch{}, false
247	}
248	switch ev.Kind {
249	case "enter":
250		if ev.Surface == m.surface {
251			m.entered = true
252			m.synced = true
253			m.batch = edit.IMEBatch{}
254			if m.requested {
255				m.enabled = false
256				m.Enable()
257			}
258		}
259	case "leave":
260		if ev.Surface == m.surface {
261			m.entered = false
262			m.enabled = false
263			m.synced = false
264			m.batch = edit.IMEBatch{}
265		}
266	case "preedit":
267		if !m.entered || !m.enabled {
268			return edit.IMEBatch{}, false
269		}
270		m.batch.Preedit = ev.Text
271		m.batch.Begin = int(ev.Begin)
272		m.batch.End = int(ev.End)
273	case "commit":
274		if !m.entered || !m.enabled {
275			return edit.IMEBatch{}, false
276		}
277		m.batch.Commit = ev.Text
278	case "delete":
279		if !m.entered || !m.enabled {
280			return edit.IMEBatch{}, false
281		}
282		m.batch.DeleteBefore = int(min(ev.Before, 4000))
283		m.batch.DeleteAfter = int(min(ev.After, 4000))
284	case "done":
285		batch := m.batch
286		m.batch = edit.IMEBatch{}
287		if !m.entered || !m.enabled {
288			return edit.IMEBatch{}, false
289		}
290		m.synced = ev.Serial == m.commits
291		if m.synced {
292			m.flushPending()
293		}
294		return batch, true
295	}
296	return edit.IMEBatch{}, false
297}
298
299// flushPending sends state only after a matching done. Mismatched done batches
300// still reach the editor, but do not modify the text-input object's state.
301func (m *IME) flushPending() {
302	if m.pendingContent {
303		m.pendingContent = false
304		m.ContentType(m.password, m.multiline)
305	}
306	if m.hasSurround {
307		s, c, a := m.pendingSurround, m.pendingCursor, m.pendingAnchor
308		m.hasSurround = false
309		m.Surrounding(s, c, a)
310	}
311	if m.pendingRect {
312		m.pendingRect = false
313		m.send(m.input.SetCursorRectangle(m.rect[0], m.rect[1], m.rect[2], m.rect[3]))
314		m.commit()
315	}
316}
317func (m *IME) Close() {
318	if m == nil {
319		return
320	}
321	if m.input != nil {
322		_ = m.input.Destroy()
323		m.input = nil
324	}
325	if m.manager != nil {
326		_ = m.manager.Destroy()
327		m.manager = nil
328	}
329}

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 12

↑↓ moveenter openesc close