commit 445d335

delthas  ·  2025-09-15 16:10:29 +0000 UTC
parent 972d647
Support custom keyboard shortcuts
6 files changed,  +366, -103
M app.go
M app.go
+225, -102
  1@@ -108,6 +108,11 @@ type pendingCompletion struct {
  2 	deadline time.Time
  3 }
  4 
  5+type keyMatch struct {
  6+	keycode rune
  7+	mods    vaxis.ModifierMask
  8+}
  9+
 10 type App struct {
 11 	win              *ui.UI
 12 	sessions         map[string]*irc.Session // map of network IDs to their current session
 13@@ -119,6 +124,7 @@ type App struct {
 14 
 15 	cfg        Config
 16 	highlights []string
 17+	shortcuts  map[keyMatch][]string
 18 
 19 	lastQuery     string
 20 	lastQueryNet  string
 21@@ -171,9 +177,19 @@ func NewApp(cfg Config) (app *App, err error) {
 22 		sessions:           map[string]*irc.Session{},
 23 		events:             make(chan event, eventChanSize),
 24 		cfg:                cfg,
 25+		shortcuts:          make(map[keyMatch][]string),
 26 		messageBounds:      map[boundKey]bound{},
 27 		monitor:            make(map[string]map[string]struct{}),
 28 	}
 29+	for _, m := range []map[string][]string{defaultCommands, app.cfg.Shortcuts} {
 30+		for name, actions := range m {
 31+			k := keyNameMatch(name)
 32+			if k == nil {
 33+				return nil, fmt.Errorf("unknown key name: %v", name)
 34+			}
 35+			app.shortcuts[*k] = actions
 36+		}
 37+	}
 38 
 39 	if cfg.Highlights != nil {
 40 		app.highlights = make([]string, len(cfg.Highlights))
 41@@ -813,119 +829,86 @@ func (app *App) handleMouseEvent(ev vaxis.Mouse) {
 42 	}
 43 }
 44 
 45-func (app *App) handleKeyEvent(ev vaxis.Key) {
 46-	switch ev.EventType {
 47-	case vaxis.EventPress, vaxis.EventRepeat, vaxis.EventPaste:
 48-	default:
 49-		return
 50-	}
 51-	if ev.Text != "" {
 52-		for _, r := range ev.Text {
 53-			app.win.InputRune(r)
 54-		}
 55-		app.typing()
 56-		return
 57-	}
 58-
 59-	if keyMatches(ev, 'c', vaxis.ModCtrl) {
 60+func (app *App) handleAction(action string, args ...string) {
 61+	switch action {
 62+	case "quit":
 63 		if app.win.InputClear() {
 64 			app.typing()
 65 		} else {
 66 			app.win.InputSet("/quit")
 67 		}
 68-	} else if keyMatches(ev, 'f', vaxis.ModCtrl) {
 69-		if len(app.win.InputContent()) == 0 {
 70-			app.win.InputSet("/search ")
 71-		}
 72-	} else if keyMatches(ev, 'k', vaxis.ModCtrl) {
 73+	case "set-editor":
 74 		if len(app.win.InputContent()) == 0 {
 75-			app.win.InputSet("/buffer ")
 76+			app.win.InputSet(strings.Join(args, " "))
 77 		}
 78-	} else if keyMatches(ev, 'a', vaxis.ModCtrl) {
 79+	case "cursor-start":
 80 		app.win.InputHome()
 81-	} else if keyMatches(ev, 'e', vaxis.ModCtrl) {
 82+	case "cursor-end":
 83 		app.win.InputEnd()
 84-	} else if keyMatches(ev, 'l', vaxis.ModCtrl) {
 85+	case "redraw":
 86 		app.win.Resize()
 87-	} else if keyMatches(ev, 'u', vaxis.ModCtrl) || keyMatches(ev, vaxis.KeyPgUp, 0) {
 88+	case "scroll-up":
 89 		app.win.ScrollUp()
 90-	} else if keyMatches(ev, 'd', vaxis.ModCtrl) || keyMatches(ev, vaxis.KeyPgDown, 0) {
 91+	case "scroll-down":
 92 		app.win.ScrollDown()
 93-	} else if keyMatches(ev, 'n', vaxis.ModCtrl) {
 94+	case "buffer-next":
 95 		app.win.NextBuffer()
 96 		app.win.ScrollToBuffer()
 97-	} else if keyMatches(ev, 'p', vaxis.ModCtrl) {
 98+	case "buffer-previous":
 99 		app.win.PreviousBuffer()
100 		app.win.ScrollToBuffer()
101-	} else if keyMatches(ev, vaxis.KeyRight, vaxis.ModAlt) {
102-		app.win.NextBuffer()
103-		app.win.ScrollToBuffer()
104-	} else if keyMatches(ev, vaxis.KeyRight, vaxis.ModShift) {
105+	case "buffer-next-unread":
106 		app.win.NextUnreadBuffer()
107 		app.win.ScrollToBuffer()
108-	} else if keyMatches(ev, vaxis.KeyRight, vaxis.ModCtrl) {
109-		app.win.InputRightWord()
110-	} else if keyMatches(ev, vaxis.KeyRight, 0) {
111-		app.win.InputRight()
112-	} else if keyMatches(ev, vaxis.KeyLeft, vaxis.ModAlt) {
113-		app.win.PreviousBuffer()
114-		app.win.ScrollToBuffer()
115-	} else if keyMatches(ev, vaxis.KeyLeft, vaxis.ModShift) {
116+	case "buffer-previous-unread":
117 		app.win.PreviousUnreadBuffer()
118 		app.win.ScrollToBuffer()
119-	} else if keyMatches(ev, vaxis.KeyLeft, vaxis.ModCtrl) {
120+	case "cursor-right-word":
121+		app.win.InputRightWord()
122+	case "cursor-left-word":
123 		app.win.InputLeftWord()
124-	} else if keyMatches(ev, vaxis.KeyLeft, 0) {
125+	case "cursor-right":
126+		app.win.InputRight()
127+	case "cursor-left":
128 		app.win.InputLeft()
129-	} else if keyMatches(ev, vaxis.KeyUp, vaxis.ModAlt) {
130-		app.win.PreviousBuffer()
131-	} else if keyMatches(ev, vaxis.KeyUp, 0) {
132+	case "cursor-up":
133 		app.win.InputUp()
134-	} else if keyMatches(ev, vaxis.KeyDown, vaxis.ModAlt) {
135-		app.win.NextBuffer()
136-	} else if keyMatches(ev, vaxis.KeyDown, 0) {
137+	case "cursor-down":
138 		app.win.InputDown()
139-	} else if keyMatches(ev, vaxis.KeyHome, vaxis.ModAlt) {
140-		app.win.GoToBufferNo(0)
141-	} else if keyMatches(ev, vaxis.KeyHome, 0) {
142-		app.win.InputHome()
143-	} else if keyMatches(ev, vaxis.KeyEnd, vaxis.ModAlt) {
144-		maxInt := int(^uint(0) >> 1)
145-		app.win.GoToBufferNo(maxInt)
146-	} else if keyMatches(ev, vaxis.KeyEnd, 0) {
147-		app.win.InputEnd()
148-	} else if keyMatches(ev, vaxis.KeyBackspace, vaxis.ModAlt) {
149+	case "cursor-delete-previous-word":
150 		if app.win.InputDeleteWord() {
151 			app.typing()
152 		}
153-	} else if keyMatches(ev, vaxis.KeyBackspace, 0) || keyMatches(ev, vaxis.KeyBackspace, vaxis.ModShift) {
154+	case "cursor-delete-previous":
155 		if app.win.InputBackspace() {
156 			app.typing()
157 		}
158-	} else if keyMatches(ev, vaxis.KeyDelete, 0) {
159+	case "cursor-delete-next":
160 		if app.win.InputDelete() {
161 			app.typing()
162 		}
163-	} else if keyMatches(ev, 'w', vaxis.ModCtrl) {
164-		if app.win.InputDeleteWord() {
165+	case "cursor-delete-before":
166+		if app.win.InputDeleteBefore() {
167 			app.typing()
168 		}
169-	} else if keyMatches(ev, 'r', vaxis.ModCtrl) {
170+	case "cursor-delete-after":
171+		if app.win.InputDeleteAfter() {
172+			app.typing()
173+		}
174+	case "search-editor":
175 		app.win.InputBackSearch()
176-	} else if keyMatches(ev, vaxis.KeyTab, 0) {
177+	case "auto-complete":
178 		if app.win.InputAutoComplete() {
179 			app.typing()
180 		}
181-	} else if keyMatches(ev, vaxis.KeyEsc, 0) {
182+	case "close-overlay":
183 		app.win.CloseOverlay()
184-	} else if keyMatches(ev, vaxis.KeyF07, 0) {
185+	case "toggle-channel-list":
186 		app.win.ToggleChannelList()
187-	} else if keyMatches(ev, vaxis.KeyF08, 0) {
188+	case "toggle-member-list":
189 		app.win.ToggleMemberList()
190-	} else if keyMatches(ev, '\n', 0) || keyMatches(ev, '\r', 0) || keyMatches(ev, 'j', vaxis.ModCtrl) || keyMatches(ev, vaxis.KeyKeyPadEnter, 0) {
191-		if ev.EventType == vaxis.EventPaste {
192-			app.win.InputRune('\n')
193-		} else if !app.win.InputEnter() {
194+	case "send":
195+		if !app.win.InputEnter() {
196 			netID, buffer := app.win.CurrentBuffer()
197 			input := string(app.win.InputContent())
198 			var err error
199@@ -945,28 +928,130 @@ func (app *App) handleKeyEvent(ev vaxis.Key) {
200 				app.win.InputFlush()
201 			}
202 		}
203-	} else if keyMatches(ev, 'n', vaxis.ModAlt) {
204+	case "scroll-next-highlight":
205 		app.win.ScrollDownHighlight()
206-	} else if keyMatches(ev, 'p', vaxis.ModAlt) {
207+	case "scroll-previous-highlight":
208 		app.win.ScrollUpHighlight()
209-	} else if keyMatches(ev, '1', vaxis.ModAlt) || keyMatches(ev, vaxis.KeyKeyPad1, vaxis.ModAlt) {
210-		app.win.GoToBufferNo(0)
211-	} else if keyMatches(ev, '2', vaxis.ModAlt) || keyMatches(ev, vaxis.KeyKeyPad2, vaxis.ModAlt) {
212-		app.win.GoToBufferNo(1)
213-	} else if keyMatches(ev, '3', vaxis.ModAlt) || keyMatches(ev, vaxis.KeyKeyPad3, vaxis.ModAlt) {
214-		app.win.GoToBufferNo(2)
215-	} else if keyMatches(ev, '4', vaxis.ModAlt) || keyMatches(ev, vaxis.KeyKeyPad4, vaxis.ModAlt) {
216-		app.win.GoToBufferNo(3)
217-	} else if keyMatches(ev, '5', vaxis.ModAlt) || keyMatches(ev, vaxis.KeyKeyPad5, vaxis.ModAlt) {
218-		app.win.GoToBufferNo(4)
219-	} else if keyMatches(ev, '6', vaxis.ModAlt) || keyMatches(ev, vaxis.KeyKeyPad6, vaxis.ModAlt) {
220-		app.win.GoToBufferNo(5)
221-	} else if keyMatches(ev, '7', vaxis.ModAlt) || keyMatches(ev, vaxis.KeyKeyPad7, vaxis.ModAlt) {
222-		app.win.GoToBufferNo(6)
223-	} else if keyMatches(ev, '8', vaxis.ModAlt) || keyMatches(ev, vaxis.KeyKeyPad8, vaxis.ModAlt) {
224-		app.win.GoToBufferNo(7)
225-	} else if keyMatches(ev, '9', vaxis.ModAlt) || keyMatches(ev, vaxis.KeyKeyPad9, vaxis.ModAlt) {
226-		app.win.GoToBufferNo(8)
227+	case "buffer":
228+		if len(args) > 0 {
229+			if n, err := strconv.Atoi(args[0]); err == nil && n >= 0 {
230+				app.win.GoToBufferNo(n)
231+			} else if args[0] == "last" {
232+				maxInt := int(^uint(0) >> 1)
233+				app.win.GoToBufferNo(maxInt)
234+			}
235+		}
236+	case "none":
237+	default:
238+		netID, buffer := app.win.CurrentBuffer()
239+		app.win.AddLine(netID, buffer, ui.Line{
240+			At:        time.Now(),
241+			Head:      "!!",
242+			HeadColor: ui.ColorRed,
243+			Notify:    ui.NotifyUnread,
244+			Body:      ui.PlainSprintf("shortcut: action %q does not exist", action),
245+		})
246+	}
247+}
248+
249+var defaultCommands = map[string][]string{
250+	"Control+c":       {"quit"},
251+	"Control+f":       {"set-editor", "/search "},
252+	"Control+k":       {"set-editor", "/buffer "},
253+	"Control+a":       {"cursor-start"},
254+	"Control+e":       {"cursor-end"},
255+	"Control+l":       {"redraw"},
256+	"Control+u":       {"scroll-up"},
257+	"Page_Up":         {"scroll-up"},
258+	"Control+d":       {"scroll-down"},
259+	"Page_Down":       {"scroll-down"},
260+	"Control+n":       {"buffer-next"},
261+	"Control+p":       {"buffer-previous"},
262+	"Alt+Right":       {"buffer-next"},
263+	"Shift+Right":     {"buffer-next-unread"},
264+	"Control+Right":   {"cursor-right-word"},
265+	"Right":           {"cursor-right"},
266+	"Alt+Left":        {"buffer-previous"},
267+	"Shift+Left":      {"buffer-previous-unread"},
268+	"Control+Left":    {"cursor-left-word"},
269+	"Left":            {"cursor-left"},
270+	"Alt+Up":          {"buffer-previous"},
271+	"Up":              {"cursor-up"},
272+	"Alt+Down":        {"buffer-next"},
273+	"Down":            {"cursor-down"},
274+	"Alt+Home":        {"buffer", "0"},
275+	"Home":            {"cursor-start"},
276+	"Alt+End":         {"buffer", "last"},
277+	"End":             {"cursor-end"},
278+	"Alt+BackSpace":   {"cursor-delete-previous-word"},
279+	"BackSpace":       {"cursor-delete-previous"},
280+	"Shift+BackSpace": {"cursor-delete-previous"},
281+	"Delete":          {"cursor-delete-next"},
282+	"Control+w":       {"cursor-delete-previous-word"},
283+	"Control+r":       {"search-editor"},
284+	"Tab":             {"auto-complete"},
285+	"Escape":          {"close-overlay"},
286+	"F7":              {"toggle-channel-list"},
287+	"F8":              {"toggle-member-list"},
288+	"\n":              {"send"},
289+	"\r":              {"send"},
290+	"Control+j":       {"send"},
291+	"KP_Enter":        {"send"},
292+	"Alt+n":           {"scroll-next-highlight"},
293+	"Alt+p":           {"scroll-previous-highlight"},
294+	"Alt+1":           {"buffer", "0"},
295+	"Alt+KP_1":        {"buffer", "0"},
296+	"Alt+2":           {"buffer", "1"},
297+	"Alt+KP_2":        {"buffer", "1"},
298+	"Alt+3":           {"buffer", "2"},
299+	"Alt+KP_3":        {"buffer", "2"},
300+	"Alt+4":           {"buffer", "3"},
301+	"Alt+KP_4":        {"buffer", "3"},
302+	"Alt+5":           {"buffer", "4"},
303+	"Alt+KP_5":        {"buffer", "4"},
304+	"Alt+6":           {"buffer", "5"},
305+	"Alt+KP_6":        {"buffer", "5"},
306+	"Alt+7":           {"buffer", "6"},
307+	"Alt+KP_7":        {"buffer", "6"},
308+	"Alt+8":           {"buffer", "7"},
309+	"Alt+KP_8":        {"buffer", "7"},
310+	"Alt+9":           {"buffer", "8"},
311+	"Alt+KP_9":        {"buffer", "8"},
312+}
313+
314+func (app *App) handleKeyEvent(ev vaxis.Key) {
315+	switch ev.EventType {
316+	case vaxis.EventPress, vaxis.EventRepeat, vaxis.EventPaste:
317+	default:
318+		return
319+	}
320+	if ev.Text != "" {
321+		for _, r := range ev.Text {
322+			app.win.InputRune(r)
323+		}
324+		app.typing()
325+		return
326+	}
327+
328+	if ev.EventType == vaxis.EventPaste {
329+		for _, keycode := range []rune{'\n', '\r', vaxis.KeyKeyPadEnter} {
330+			k := keyMatch{
331+				keycode: keycode,
332+			}
333+			for _, km := range keyMatches(ev) {
334+				if km == k {
335+					app.win.InputRune('\n')
336+					return
337+				}
338+			}
339+		}
340+	}
341+
342+	for _, km := range keyMatches(ev) {
343+		if d := app.shortcuts[km]; len(d) != 0 {
344+			app.handleAction(d[0], d[1:]...)
345+			return
346+		}
347 	}
348 }
349 
350@@ -2338,23 +2423,61 @@ func (app *App) addUserBuffer(netID, buffer string, t time.Time) (i int, added b
351 	return
352 }
353 
354-func keyMatches(k vaxis.Key, r rune, mods vaxis.ModifierMask) bool {
355+func keyNameMatch(name string) *keyMatch {
356+	parts := strings.Split(name, "+")
357+	mods := parts[:len(parts)-1]
358+	key := parts[len(parts)-1]
359+
360+	var m vaxis.ModifierMask
361+	for _, mod := range mods {
362+		switch mod {
363+		case "Control":
364+			m |= vaxis.ModCtrl
365+		case "Shift":
366+			m |= vaxis.ModShift
367+		case "Alt":
368+			m |= vaxis.ModAlt
369+		case "Super":
370+			m |= vaxis.ModSuper
371+		default:
372+			return nil
373+		}
374+	}
375+	if r, n := utf8.DecodeRuneInString(key); n == len(key) {
376+		return &keyMatch{
377+			keycode: r,
378+			mods:    m,
379+		}
380+	}
381+	if r := ui.KeyNames[key]; r > 0 {
382+		return &keyMatch{
383+			keycode: r,
384+			mods:    m,
385+		}
386+	}
387+	return nil
388+}
389+
390+func keyMatches(k vaxis.Key) []keyMatch {
391 	m := k.Modifiers
392 	m &^= vaxis.ModCapsLock
393 	m &^= vaxis.ModNumLock
394-	if k.Keycode == r && mods == m {
395-		// ctrl+a and user pressed ctrl+a
396-		// ctrl+. and user pressed ctrl+. on a US keyboard
397-		return true
398+
399+	keys := []keyMatch{
400+		{
401+			keycode: k.Keycode,
402+			mods:    m,
403+		},
404 	}
405-	if m&vaxis.ModShift != 0 {
406+	if m&vaxis.ModShift != 0 && k.ShiftedCode != 0 {
407+		// ctrl+. and user pressed ctrl+shift+; on a French keyboard
408 		m &^= vaxis.ModShift
409-		if k.ShiftedCode == r && mods == m {
410-			// ctrl+. and user pressed ctrl+shift+; on a French keyboard
411-			return true
412-		}
413+		keys = append(keys, keyMatch{
414+			keycode: k.ShiftedCode,
415+			mods:    m &^ vaxis.ModShift,
416+		})
417 	}
418-	return false
419+	return keys
420 }
421 
422 func formatSize(v int64) string {
+9, -0
 1@@ -111,6 +111,7 @@ type Config struct {
 2 	MemberColEnabled bool
 3 	TextMaxWidth     int
 4 	StatusEnabled    bool
 5+	Shortcuts        map[string][]string
 6 
 7 	Colors ui.ConfigColors
 8 
 9@@ -161,6 +162,7 @@ func Defaults() Config {
10 				Others: ui.ColorDefault,
11 			},
12 		},
13+		Shortcuts:         make(map[string][]string),
14 		Debug:             false,
15 		Transient:         false,
16 		LocalIntegrations: true,
17@@ -442,6 +444,13 @@ func unmarshal(filename string, cfg *Config) (err error) {
18 					return fmt.Errorf("unknown colors directive %q", child.Name)
19 				}
20 			}
21+		case "shortcuts":
22+			for _, child := range d.Children {
23+				if err := child.ParseParams(nil); err != nil {
24+					return err
25+				}
26+				cfg.Shortcuts[child.Name] = child.Params
27+			}
28 		case "debug":
29 			var debug string
30 			if err := d.ParseParams(&debug); err != nil {
+3, -1
 1@@ -91,6 +91,8 @@ specified above, instead of an unmodified left click.
 2 
 3 # KEYBOARD SHORTCUTS
 4 
 5+These shortcuts can be customized in the configuration, see senpai(5).
 6+
 7 *CTRL-A*
 8 	Move the cursor to the beginning of the input field.
 9 
10@@ -98,7 +100,7 @@ specified above, instead of an unmodified left click.
11 	Move the cursor to the end of the input field.
12 
13 *CTRL-C*
14-	Clear input line, or prepare for exit by adding /exit to input line.
15+	Clear input line, or prepare for exit by adding /quit to input line.
16 
17 *CTRL-F*
18 	Prepare for search: add /search to input line.
+91, -0
  1@@ -166,6 +166,94 @@ colors {
  2 |  nicks self <self>
  3 :  show self nick with a fixed specified color (can be added along other directives)
  4 
  5+*shortcuts* { ... }
  6+	Settings for custom keyboard shortcuts.
  7+
  8+	Shortcuts are defined by `*<key> <action> [<params>...]*` subdirectives:
  9+
 10+```
 11+shortcuts {
 12+    Alt+k cursor-delete-after
 13+    Shift+Alt+Page_Up set-editor "/me slaps OP with a large trout"
 14+}
 15+```
 16+
 17+	Keys are optional modifiers (*Control*/*Shift*/*Alt*/*Super*) and a key name,
 18+	which can either be a literal character (e.g. *k*) or a name for that key,
 19+	taken from the XKB key names (on Linux, see *wev* for finding key names,
 20+	e.g. *Page_Up*, *XF86AudioPlay*).
 21+
 22+	A custom shortcut will override any default shortcut for that key.
 23+
 24+[[ *action*
 25+:< *Description*
 26+|  none
 27+:  *clears the shortcut, no action*
 28+|  quit
 29+:  clear the editor, or type `/quit`
 30+|  set-editor <text>
 31+:  set the editor to the passed text, if empty
 32+|  cursor-start
 33+:  move the cursor to the beginning of the editor
 34+|  cursor-end
 35+:  move the cursor to the end of the editor
 36+|  redraw
 37+:  refresh the window
 38+|  scroll-up
 39+:  go up in the timeline
 40+|  scroll-down
 41+:  go down in the timeline
 42+|  scroll-next-highlight
 43+:  go down to the next highlight
 44+|  scroll-previous-highlight
 45+:  go up to the next highlight
 46+|  buffer-next
 47+:  go to the next buffer
 48+|  buffer-previous
 49+:  go to the previous buffer
 50+|  buffer-next-unread
 51+:  go to the next unread buffer
 52+|  buffer-previous-unread
 53+:  go to the previous unread buffer
 54+|  cursor-right-word
 55+:  move the cursor to the next word
 56+|  cursor-left-word
 57+:  move the cursor to the previous word
 58+|  cursor-right
 59+:  move the cursor to the next character
 60+|  cursor-left
 61+:  move the cursor to the previous character
 62+|  cursor-up
 63+:  move the cursor to the previous character
 64+|  cursor-up
 65+:  scroll back one line in the editor
 66+|  cursor-down
 67+:  scroll forward one line in the editor
 68+|  cursor-delete-previous-word
 69+:  delete the previous word in the editor
 70+|  cursor-delete-previous
 71+:  delete the previous character in the editor
 72+|  cursor-delete-next
 73+:  delete the next character in the editor
 74+|  cursor-delete-before
 75+:  delete from the cursor to the beginning of the line
 76+|  cursor-delete-after
 77+:  delete from the cursor to the end of the line
 78+|  search-editor
 79+:  reverse-search in the editor history
 80+|  auto-complete
 81+:  open/select the auto-completion dialog/item
 82+|  close-overlay
 83+:  close any open overlay buffer (e.g. search results)
 84+|  toggle-channel-list
 85+:  show/hide the vertical channel list
 86+|  toggle-member-list
 87+:  show/hide the vertical member list
 88+|  send
 89+:  send the contents of the editor
 90+|  buffer <number>|_last_
 91+:  go the 0-indexed numbered buffer, or the last one
 92+
 93 *debug*
 94 	Advanced.
 95 	Dump all sent and received data to the home buffer, useful for debugging.
 96@@ -254,6 +342,9 @@ pane-widths {
 97 colors {
 98 	nicks extended
 99 }
100+shortcuts {
101+    Alt+k cursor-delete-after
102+}
103 ```
104 
105 # SEE ALSO
+30, -0
 1@@ -211,6 +211,36 @@ func (e *Editor) RemClusterForward() (ok bool) {
 2 	return
 3 }
 4 
 5+func (e *Editor) RemBefore() (ok bool) {
 6+	ok = e.cursorIdx > 0
 7+	if !ok {
 8+		return
 9+	}
10+	e.text[e.lineIdx].runes = e.text[e.lineIdx].runes[e.text[e.lineIdx].clusters[e.cursorIdx]:]
11+	e.cursorIdx = 0
12+	e.offsetIdx = 0
13+
14+	e.recompute()
15+	e.bumpOldestChange()
16+	e.autoCache = nil
17+	e.backsearchEnd()
18+	return
19+}
20+
21+func (e *Editor) RemAfter() (ok bool) {
22+	ok = e.cursorIdx < len(e.text[e.lineIdx].clusters)-1
23+	if !ok {
24+		return
25+	}
26+	e.text[e.lineIdx].runes = e.text[e.lineIdx].runes[:e.text[e.lineIdx].clusters[e.cursorIdx]]
27+
28+	e.recompute()
29+	e.bumpOldestChange()
30+	e.autoCache = nil
31+	e.backsearchEnd()
32+	return
33+}
34+
35 func (e *Editor) remClusterAt(idx int) {
36 	rs := e.text[e.lineIdx].clusters[idx]
37 	re := e.text[e.lineIdx].clusters[idx+1]
+8, -0
 1@@ -652,6 +652,14 @@ func (ui *UI) InputDelete() (ok bool) {
 2 	return ui.e.RemClusterForward()
 3 }
 4 
 5+func (ui *UI) InputDeleteBefore() (ok bool) {
 6+	return ui.e.RemBefore()
 7+}
 8+
 9+func (ui *UI) InputDeleteAfter() (ok bool) {
10+	return ui.e.RemAfter()
11+}
12+
13 func (ui *UI) InputDeleteWord() (ok bool) {
14 	return ui.e.RemWord()
15 }