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}