v0.4.0 2de4e1d

edit.go RawHistoryFolder

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}

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