detached ece7682

input.go RawHistoryFolder

1package webterm
2
3import (
4	"context"
5	"fmt"
6	"strings"
7	"unicode/utf8"
8
9	"github.com/bnema/vev-vt/html/browser"
10	"github.com/bnema/vev/internal/domain"
11)
12
13// Handle translates validated browser events into ordinary terminal input.
14// Paste never admits embedded terminal framing or C0 commands other than text
15// whitespace. The client retains its normal bracketed-paste policy.
16func (t *Terminal) Handle(ctx context.Context, event browser.Event) error {
17	snapshot := t.Snapshot()
18	modes := snapshot.Modes()
19	var data string
20	switch event.Kind {
21	case browser.EventResize:
22		e := event.Resize
23		return t.Resize(domain.Geometry{Size: domain.Size{Cols: e.Columns, Rows: e.Rows}, PixelWidth: int(e.PixelWidth), PixelHeight: int(e.PixelHeight)})
24	case browser.EventText:
25		data = cleanText(event.Text.Text, false)
26	case browser.EventPaste:
27		data = cleanText(event.Paste.Text, true)
28		if modes.BracketedPaste {
29			data = "\x1b[200~" + data + "\x1b[201~"
30		}
31	case browser.EventKey:
32		data = encodeKey(*event.Key, snapshot.ApplicationCursorMode())
33	case browser.EventPointer:
34		e := event.Pointer
35		if modes.MouseTracking == 0 || !modes.MouseSGR || e.Row < 0 || e.Column < 0 || e.Row >= snapshot.Rows() || e.Column >= snapshot.Columns() {
36			return nil
37		}
38		button := e.Button
39		final := "M"
40		switch e.Action {
41		case "down":
42			if button < 0 || button > 2 {
43				return nil
44			}
45		case "up":
46			if button < 0 || button > 2 {
47				return nil
48			}
49			final = "m"
50		case "move":
51			if modes.MouseTracking != 1003 && (modes.MouseTracking != 1002 || e.Buttons == 0) {
52				return nil
53			}
54			button = 3
55			if e.Buttons&1 != 0 {
56				button = 0
57			} else if e.Buttons&4 != 0 {
58				button = 1
59			} else if e.Buttons&2 != 0 {
60				button = 2
61			}
62			button += 32
63		default:
64			return nil
65		}
66		data = fmt.Sprintf("\x1b[<%d;%d;%d%s", button+mouseModifiers(e.Modifiers), e.Column+1, e.Row+1, final)
67	case browser.EventWheel:
68		e := event.Wheel
69		if modes.MouseTracking == 0 || !modes.MouseSGR || e.Row < 0 || e.Column < 0 || e.Row >= snapshot.Rows() || e.Column >= snapshot.Columns() {
70			t.resetWheel()
71			return nil
72		}
73		if e.DeltaY == 0 {
74			return nil
75		}
76		reports, button := t.consumeWheel(e.DeltaY, e.DeltaMode, e.Modifiers.Shift)
77		if reports == 0 {
78			return nil
79		}
80		// Shift is consumed as the x10 fast-scroll multiplier, so it must
81		// not also leak into the SGR modifiers (buttons 68/69), which
82		// applications would not recognize as wheel reports.
83		modifiers := e.Modifiers
84		if e.Modifiers.Shift {
85			modifiers.Shift = false
86		}
87		var sb strings.Builder
88		for i := 0; i < reports; i++ {
89			fmt.Fprintf(&sb, "\x1b[<%d;%d;%dM", button+mouseModifiers(modifiers), e.Column+1, e.Row+1)
90		}
91		data = sb.String()
92	case browser.EventFocus:
93		// The browser page stays out of focus reporting: web clients keep
94		// unknown focus and count as possibly seen.
95		return nil
96	}
97	return t.Send(ctx, []byte(data))
98}
99
100func mouseModifiers(m browser.Modifiers) int {
101	n := 0
102	if m.Shift {
103		n += 4
104	}
105	if m.Alt {
106		n += 8
107	}
108	if m.Ctrl {
109		n += 16
110	}
111	return n
112}
113
114func cleanText(text string, whitespace bool) string {
115	return strings.Map(func(r rune) rune {
116		if whitespace && (r == '\n' || r == '\r' || r == '\t') {
117			return r
118		}
119		if r < 32 || r >= 127 && r <= 159 {
120			return -1
121		}
122		return r
123	}, text)
124}
125
126func encodeKey(e browser.KeyEvent, applicationCursor bool) string {
127	if e.Modifiers.Meta {
128		return ""
129	}
130	modifier := 1
131	if e.Modifiers.Shift {
132		modifier += 1
133	}
134	if e.Modifiers.Alt {
135		modifier += 2
136	}
137	if e.Modifiers.Ctrl {
138		modifier += 4
139	}
140	var final string
141	switch e.Key {
142	case "ArrowUp":
143		final = "A"
144	case "ArrowDown":
145		final = "B"
146	case "ArrowRight":
147		final = "C"
148	case "ArrowLeft":
149		final = "D"
150	case "Home":
151		final = "H"
152	case "End":
153		final = "F"
154	}
155	if final != "" {
156		if modifier > 1 {
157			return fmt.Sprintf("\x1b[1;%d%s", modifier, final)
158		}
159		if applicationCursor {
160			return "\x1bO" + final
161		}
162		return "\x1b[" + final
163	}
164	number := 0
165	switch e.Key {
166	case "Insert":
167		number = 2
168	case "Delete":
169		number = 3
170	case "PageUp":
171		number = 5
172	case "PageDown":
173		number = 6
174	case "F5":
175		number = 15
176	case "F6":
177		number = 17
178	case "F7":
179		number = 18
180	case "F8":
181		number = 19
182	case "F9":
183		number = 20
184	case "F10":
185		number = 21
186	case "F11":
187		number = 23
188	case "F12":
189		number = 24
190	}
191	if number != 0 {
192		if modifier > 1 {
193			return fmt.Sprintf("\x1b[%d;%d~", number, modifier)
194		}
195		return fmt.Sprintf("\x1b[%d~", number)
196	}
197	if len(e.Key) == 2 && e.Key[0] == 'F' && e.Key[1] >= '1' && e.Key[1] <= '4' {
198		final := string(rune('P' + e.Key[1] - '1'))
199		if modifier > 1 {
200			return fmt.Sprintf("\x1b[1;%d%s", modifier, final)
201		}
202		return "\x1bO" + final
203	}
204	var key string
205	switch e.Key {
206	case "Enter":
207		key = "\r"
208	case "Escape":
209		key = "\x1b"
210	case "Backspace":
211		key = "\x7f"
212	case "Tab":
213		if e.Modifiers.Shift {
214			if e.Modifiers.Alt {
215				return "\x1b\x1b[Z"
216			}
217			return "\x1b[Z"
218		}
219		key = "\t"
220	default:
221		if utf8.RuneCountInString(e.Key) != 1 {
222			return ""
223		}
224		key = cleanText(e.Key, false)
225		if e.Modifiers.Ctrl {
226			upper := strings.ToUpper(key)
227			if upper == " " || upper == "@" {
228				key = "\x00"
229			} else if len(upper) == 1 && upper[0] >= 'A' && upper[0] <= '_' {
230				key = string([]byte{upper[0] & 31})
231			} else {
232				return ""
233			}
234		}
235	}
236	if e.Modifiers.Alt {
237		key = "\x1b" + key
238	}
239	return key
240}

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 12

Tags 11

↑↓ moveenter openesc close