v0.7.2 71ebe61

edit_geometry.go RawHistoryFolder

1package ui
2
3import (
4	"math"
5	"sort"
6	"unicode/utf8"
7
8	"github.com/bnema/nefergui/internal/edit"
9	"github.com/bnema/nefergui/internal/layout"
10	"github.com/go-text/typesetting/di"
11)
12
13// textBoundary is one painted edge of a grapheme cluster. trailing marks the
14// far edge of the character that precedes byteOffset; otherwise it is the
15// leading edge of the character that follows. line indexes layout.Result.Lines.
16type textBoundary struct {
17	byteOffset int
18	x, y       float64
19	line       int
20	trailing   bool
21}
22
23// textBoundaries projects editor byte boundaries onto laid-out glyph clusters.
24// Runs are in visual order; glyph cluster indices are source rune offsets.
25func textBoundaries(value string, n *layout.Result, display []layout.Command) [][]textBoundary {
26	if n == nil {
27		return nil
28	}
29	offsets := edit.Boundaries(value)
30	runeAt := make(map[int]int, len(offsets))
31	for _, b := range offsets {
32		runeAt[utf8.RuneCountInString(value[:b])] = b
33	}
34	var runs []layout.Run
35	for _, cmd := range display {
36		if cmd.ID == n.ID && cmd.Op == "text" {
37			runs = cmd.Runs
38			break
39		}
40	}
41	var lines [][]textBoundary
42	ri := 0
43	top := n.Content.Y
44	for _, line := range n.Lines {
45		current := []textBoundary{}
46		for _, shaped := range line.Runs {
47			if ri >= len(runs) {
48				break
49			}
50			run := runs[ri]
51			ri++
52			// Cluster start and end positions are derived from the glyph ink advances,
53			// not rune counts (ligatures and combining graphemes have one visual span).
54			type span struct{ low, high float64 }
55			spans := make(map[int]span)
56			for _, g := range run.Glyphs {
57				p, ok := spans[g.Cluster]
58				if !ok {
59					p = span{g.X, g.X + g.Advance}
60				} else {
61					p.low = math.Min(p.low, g.X)
62					p.high = math.Max(p.high, g.X+g.Advance)
63				}
64				spans[g.Cluster] = p
65			}
66			starts := make([]int, 0, len(spans))
67			for k := range spans {
68				starts = append(starts, k)
69			}
70			sort.Ints(starts)
71			for i, k := range starts {
72				end := shaped.End
73				if i+1 < len(starts) {
74					end = starts[i+1]
75				}
76				p := spans[k]
77				a, b := p.low, p.high
78				if shaped.Direction == di.DirectionRTL {
79					a, b = b, a
80				}
81				if offset, ok := runeAt[k]; ok {
82					current = append(current, textBoundary{byteOffset: offset, x: a, y: top, line: len(lines)})
83				}
84				if offset, ok := runeAt[end]; ok {
85					current = append(current, textBoundary{byteOffset: offset, x: b, y: top, line: len(lines), trailing: true})
86				}
87			}
88		}
89		if len(current) == 0 { // empty paragraph, including a trailing newline
90			offset := 0
91			if len(lines) > 0 && len(lines[len(lines)-1]) > 0 {
92				for _, boundary := range lines[len(lines)-1] {
93					if boundary.byteOffset > offset {
94						offset = boundary.byteOffset
95					}
96				}
97				for offset < len(value) && value[offset] == '\n' {
98					offset++
99				}
100			}
101			current = append(current, textBoundary{byteOffset: offset, x: n.Content.X, y: top, line: len(lines)})
102		}
103		lines = append(lines, current)
104		top += line.Height
105	}
106	return lines
107}
108
109// displayText is the text an editor shows for a logical value, with the
110// mapping between their byte offsets. A password shows one bullet per grapheme
111// cluster, so logical and shown offsets differ; otherwise they are identical.
112// It is the only place that maps password offsets.
113type displayText struct {
114	shown            string
115	logical, display []int // grapheme boundaries of the value and of shown; nil when identical
116}
117
118func newDisplayText(value string, password bool) displayText {
119	if !password {
120		return displayText{shown: value}
121	}
122	shown := (&edit.State{Value: value}).Mask()
123	return displayText{shown: shown, logical: edit.Boundaries(value), display: edit.Boundaries(shown)}
124}
125
126// toShown maps a logical byte offset to the shown string. An offset inside a
127// grapheme cluster maps to the start of that cluster.
128func (d displayText) toShown(offset int) int {
129	if d.logical == nil {
130		return offset
131	}
132	i := sort.SearchInts(d.logical, offset+1) - 1
133	if i < 0 {
134		i = 0
135	}
136	return d.display[i]
137}
138
139// toLogical maps a byte offset of the shown string back to the value.
140func (d displayText) toLogical(offset int) int {
141	if d.logical == nil {
142		return offset
143	}
144	i := sort.SearchInts(d.display, offset+1) - 1
145	if i < 0 {
146		i = 0
147	}
148	return d.logical[i]
149}
150
151// editorGeometry is the single owner of where editor byte offsets sit on
152// screen. Every caller (pointer placement, vertical movement, selection and
153// caret painting, caret scrolling, preedit origin) reads it, so they agree.
154// Boundary offsets are logical value offsets, password or not.
155//
156// Caret policy: a boundary can have several visual positions (bidi run
157// changes, soft wraps). The caret sits at the trailing edge of the character
158// before the offset; the leading edge of the following character is used only
159// when no character precedes the offset.
160type editorGeometry struct {
161	node  *layout.Result
162	lines [][]textBoundary
163}
164
165func newEditorGeometry(s *edit.State, n *layout.Result, display []layout.Command, password bool) editorGeometry {
166	text := newDisplayText(s.Value, password)
167	lines := textBoundaries(text.shown, n, display)
168	if text.logical != nil {
169		for _, line := range lines {
170			for i := range line {
171				line[i].byteOffset = text.toLogical(line[i].byteOffset)
172			}
173		}
174	}
175	return editorGeometry{node: n, lines: lines}
176}
177
178// caret returns the visual caret position for a logical offset.
179func (g editorGeometry) caret(offset int) (textBoundary, bool) {
180	var leading *textBoundary
181	for i := range g.lines {
182		b, trailing := preferredBoundary(g.lines[i], offset)
183		if b == nil {
184			continue
185		}
186		if trailing {
187			return *b, true
188		}
189		if leading == nil {
190			leading = b
191		}
192	}
193	if leading == nil {
194		return textBoundary{}, false
195	}
196	return *leading, true
197}
198
199// caretOnLine applies the caret policy within one visual line.
200func (g editorGeometry) caretOnLine(line, offset int) (textBoundary, bool) {
201	b, _ := preferredBoundary(g.lines[line], offset)
202	if b == nil {
203		return textBoundary{}, false
204	}
205	return *b, true
206}
207
208// preferredBoundary picks the trailing edge of the preceding character over
209// the leading edge of the following one, else the first leading edge.
210func preferredBoundary(line []textBoundary, offset int) (*textBoundary, bool) {
211	var leading *textBoundary
212	for j := range line {
213		b := &line[j]
214		if b.byteOffset != offset {
215			continue
216		}
217		if b.trailing {
218			return b, true
219		}
220		if leading == nil {
221			leading = b
222		}
223	}
224	return leading, false
225}
226
227// lineHeight is the height of a visual line, falling back to the content box.
228func (g editorGeometry) lineHeight(line int) float64 {
229	if line >= 0 && line < len(g.node.Lines) {
230		return g.node.Lines[line].Height
231	}
232	return g.node.Content.H
233}
234
235// nearest returns the logical offset whose boundary is horizontally closest
236// to (x, y) on the visual line under y.
237func (g editorGeometry) nearest(x, y float64) int {
238	if len(g.lines) == 0 {
239		return 0
240	}
241	line := 0
242	top := g.node.Content.Y
243	for i, shaped := range g.node.Lines {
244		if y >= top+shaped.Height/2 {
245			line = i
246		}
247		top += shaped.Height
248	}
249	if line >= len(g.lines) {
250		line = len(g.lines) - 1
251	}
252	best := 0
253	distance := math.Inf(1)
254	for _, b := range g.lines[line] {
255		if d := math.Abs(b.x - x); d < distance {
256			distance = d
257			best = b.byteOffset
258		}
259	}
260	return best
261}

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