1// Package edit owns headless, grapheme-safe text state. Offsets are UTF-8 bytes;
2// caret movement is logical, not visual. Rendering may project these offsets
3// onto bidi runs to paint discontiguous selection rectangles.
4package edit
5
6import (
7 "github.com/go-text/typesetting/segmenter"
8 "strings"
9 "unicode/utf8"
10)
11
12const MaxClipboardBytes = 1 << 20
13
14// Clipboard is a bounded text/plain;charset=utf-8 port. Implementations own
15// transport, cancellation and file descriptors; the editor never retains one.
16type Clipboard interface {
17 ReadText(limit int) ([]byte, error)
18 WriteText([]byte) error
19}
20
21// IME is a text-input-v3-shaped output port. All offsets are UTF-8 bytes.
22type IME interface {
23 Enable()
24 Disable()
25 Surrounding(text string, cursor, anchor int)
26 CursorRect(x, y, w, h float64)
27 ContentType(password, multiline bool)
28}
29type State struct {
30 Value string
31 Cursor, Anchor int
32 PreferredX float64
33 PreferredXValid bool // retained across successive wrapped-line moves
34 Preedit string
35 PreeditBegin, PreeditEnd int
36 active bool
37}
38
39func Boundaries(s string) []int {
40 r := []rune(s)
41 var seg segmenter.Segmenter
42 seg.Init(r)
43 it := seg.GraphemeIterator()
44 b := []int{0}
45 offset := 0
46 for it.Next() {
47 offset += len(string(it.Grapheme().Text))
48 b = append(b, offset)
49 }
50 return b
51}
52func (s *State) Sync(value string) {
53 if !utf8.ValidString(value) {
54 value = strings.ToValidUTF8(value, "\uFFFD")
55 }
56 s.Value = value
57 s.Cursor = clamp(value, s.Cursor)
58 s.Anchor = clamp(value, s.Anchor)
59}
60func clamp(s string, pos int) int {
61 b := Boundaries(s)
62 if pos < 0 {
63 return 0
64 }
65 last := 0
66 for _, v := range b {
67 if v > pos {
68 break
69 }
70 last = v
71 }
72 return last
73}
74func (s *State) Select(start, end int) {
75 s.Anchor = clamp(s.Value, start)
76 s.Cursor = clamp(s.Value, end)
77}
78func (s *State) Range() (int, int) {
79 if s.Cursor < s.Anchor {
80 return s.Cursor, s.Anchor
81 }
82 return s.Anchor, s.Cursor
83}
84func (s *State) Move(to int, shift bool) {
85 s.Cursor = clamp(s.Value, to)
86 if !shift {
87 s.Anchor = s.Cursor
88 }
89}
90func (s *State) Left(shift bool) {
91 b := Boundaries(s.Value)
92 p := 0
93 for _, v := range b {
94 if v >= s.Cursor {
95 break
96 }
97 p = v
98 }
99 s.Move(p, shift)
100}
101func (s *State) Right(shift bool) {
102 for _, v := range Boundaries(s.Value) {
103 if v > s.Cursor {
104 s.Move(v, shift)
105 return
106 }
107 }
108 s.Move(len(s.Value), shift)
109}
110func (s *State) replace(start, end int, text string) bool {
111 if !utf8.ValidString(text) {
112 return false
113 }
114 start = clamp(s.Value, start)
115 end = clamp(s.Value, end)
116 if end < start {
117 start, end = end, start
118 }
119 next := s.Value[:start] + text + s.Value[end:]
120 if next == s.Value {
121 return false
122 }
123 s.Value = next
124 // A newly inserted combining mark can merge into the previous grapheme.
125 // Round forward to the first boundary at or after the insertion endpoint.
126 endOfInsert := start + len(text)
127 for _, boundary := range Boundaries(next) {
128 if boundary >= endOfInsert {
129 s.Move(boundary, false)
130 break
131 }
132 }
133 return true
134}
135func (s *State) Insert(text string) bool { a, b := s.Range(); return s.replace(a, b, text) }
136func (s *State) Delete(backward bool, word bool) bool {
137 a, b := s.Range()
138 if a == b {
139 if word {
140 if backward {
141 a = s.WordLeft()
142 } else {
143 b = s.WordRight()
144 }
145 } else {
146 if backward {
147 p := *s
148 p.Left(false)
149 a = p.Cursor
150 } else {
151 p := *s
152 p.Right(false)
153 b = p.Cursor
154 }
155 }
156 }
157 return s.replace(a, b, "")
158}
159func (s *State) WordLeft() int {
160 var seg segmenter.Segmenter
161 seg.Init([]rune(s.Value))
162 it := seg.WordIterator()
163 p := 0
164 for it.Next() {
165 w := it.Word()
166 start := len(string([]rune(s.Value)[:w.Offset]))
167 if start >= s.Cursor {
168 break
169 }
170 p = start
171 }
172 return clamp(s.Value, p)
173}
174func (s *State) WordRight() int {
175 var seg segmenter.Segmenter
176 seg.Init([]rune(s.Value))
177 it := seg.WordIterator()
178 for it.Next() {
179 w := it.Word()
180 end := len(string([]rune(s.Value)[:w.Offset+len(w.Text)]))
181 if end > s.Cursor {
182 return clamp(s.Value, end)
183 }
184 }
185 return len(s.Value)
186}
187
188// WordAt returns exactly the word-iterator segment containing the byte hit.
189// On a boundary it prefers the segment to the right; whitespace and punctuation
190// are segments in their own right. Offsets and results are grapheme-aligned.
191func (s *State) WordAt(offset int) (int, int) {
192 offset = clamp(s.Value, offset)
193 runes := []rune(s.Value)
194 var seg segmenter.Segmenter
195 seg.Init(runes)
196 it := seg.WordIterator()
197 previous := 0
198 for it.Next() {
199 w := it.Word()
200 start := clamp(s.Value, len(string(runes[:w.Offset])))
201 end := clamp(s.Value, len(string(runes[:w.Offset+len(w.Text)])))
202 // The iterator skips non-word spans. Preserve those as their own
203 // segment instead of expanding a hit across adjacent words.
204 if offset >= previous && offset < start {
205 return previous, start
206 }
207 if offset >= start && offset < end {
208 return start, end
209 }
210 previous = end
211 }
212 if offset < len(s.Value) {
213 return previous, len(s.Value)
214 }
215 return len(s.Value), len(s.Value)
216}
217func (s *State) Mask() string { return strings.Repeat("•", len(Boundaries(s.Value))-1) }
218func (s *State) Copy(c Clipboard, password bool) error {
219 if c == nil || password {
220 return nil
221 }
222 a, b := s.Range()
223 if b-a > MaxClipboardBytes {
224 return nil
225 }
226 return c.WriteText([]byte(s.Value[a:b]))
227}
228func (s *State) Cut(c Clipboard, password bool) (bool, error) {
229 if c == nil || password {
230 return false, nil
231 }
232 a, b := s.Range()
233 if a == b {
234 return false, nil
235 }
236 if err := s.Copy(c, false); err != nil {
237 return false, err
238 }
239 return s.replace(a, b, ""), nil
240}
241func (s *State) Paste(c Clipboard) (bool, error) {
242 if c == nil {
243 return false, nil
244 }
245 data, err := c.ReadText(MaxClipboardBytes + 1)
246 if err != nil {
247 return false, err
248 }
249 if len(data) > MaxClipboardBytes || !utf8.Valid(data) {
250 return false, nil
251 }
252 return s.Insert(string(data)), nil
253}
254func (s *State) Focus(ime IME, password, multiline bool) {
255 s.active = ime != nil
256 if ime != nil {
257 ime.Enable()
258 ime.ContentType(password, multiline)
259 s.Surround(ime, password)
260 }
261}
262func (s *State) Blur(ime IME) {
263 if s.active && ime != nil {
264 ime.Disable()
265 }
266 s.active = false
267 s.Preedit = ""
268 s.PreeditBegin = 0
269 s.PreeditEnd = 0
270}
271func (s *State) Surround(ime IME, password bool) {
272 if ime == nil {
273 return
274 }
275 if password {
276 ime.Surrounding("", 0, 0)
277 } else {
278 ime.Surrounding(s.Value, s.Cursor, s.Anchor)
279 }
280}
281func (s *State) IMEActive() bool { return s.active }
282
283// IMEBatch is applied atomically on text-input-v3 done. Delete offsets are
284// relative to the cursor in bytes, and rounded outwards to grapheme edges.
285type IMEBatch struct {
286 Preedit string
287 Begin, End int
288 Commit string
289 DeleteBefore, DeleteAfter int
290}
291
292func (s *State) Done(b IMEBatch) bool {
293 changed := false
294 if b.DeleteBefore > 0 || b.DeleteAfter > 0 {
295 a := s.Cursor - b.DeleteBefore
296 if a < 0 {
297 a = 0
298 }
299 z := s.Cursor + b.DeleteAfter
300 if z > len(s.Value) {
301 z = len(s.Value)
302 }
303 a = clamp(s.Value, a)
304 for _, v := range Boundaries(s.Value) {
305 if v >= z {
306 z = v
307 break
308 }
309 }
310 changed = s.replace(a, z, "")
311 }
312 if b.Commit != "" {
313 changed = s.Insert(b.Commit) || changed
314 // A commit consumes the outstanding preedit even when no new preedit is sent.
315 s.Preedit = ""
316 s.PreeditBegin, s.PreeditEnd = 0, 0
317 }
318 if utf8.ValidString(b.Preedit) {
319 s.Preedit = b.Preedit
320 s.PreeditBegin = b.Begin
321 s.PreeditEnd = b.End
322 } else {
323 s.Preedit = ""
324 s.PreeditBegin, s.PreeditEnd = 0, 0
325 }
326 return changed
327}