commit ffb806d

delthas  ·  2024-07-02 21:49:11 +0000 UTC
parent e64baed
Move to Vaxis (from vaxis-tcell)

This brings, at long last, proper multi-codepoint grapheme cluster
support. Meaning, editing ZWJ emoji now works flawlessly.

Fixes: https://todo.sr.ht/~delthas/senpai/57
16 files changed,  +1088, -810
M app.go
M go.mod
M go.sum
M app.go
+267, -223
  1@@ -14,7 +14,6 @@ import (
  2 	"unicode/utf8"
  3 
  4 	"git.sr.ht/~rockorager/vaxis"
  5-	"github.com/gdamore/tcell/v2"
  6 	"golang.org/x/net/context"
  7 	"golang.org/x/net/proxy"
  8 
  9@@ -172,10 +171,9 @@ func NewApp(cfg Config) (app *App, err error) {
 10 			content: ev,
 11 		}
 12 	})
 13-	app.win.SetPrompt(ui.Styled(">",
 14-		tcell.
 15-			StyleDefault.
 16-			Foreground(app.cfg.Colors.Prompt)),
 17+	app.win.SetPrompt(ui.Styled(">", vaxis.Style{
 18+		Foreground: app.cfg.Colors.Prompt,
 19+	}),
 20 	)
 21 
 22 	app.initWindow()
 23@@ -375,7 +373,7 @@ func (app *App) ircLoop(netID string) {
 24 		}
 25 		app.queueStatusLine(netID, ui.Line{
 26 			Head:      "!!",
 27-			HeadColor: tcell.ColorRed,
 28+			HeadColor: ui.ColorRed,
 29 			Body:      ui.PlainString("Connection lost"),
 30 		})
 31 	}
 32@@ -392,7 +390,7 @@ func (app *App) connect(netID string) net.Conn {
 33 	}
 34 	app.queueStatusLine(netID, ui.Line{
 35 		Head:      "!!",
 36-		HeadColor: tcell.ColorRed,
 37+		HeadColor: ui.ColorRed,
 38 		Body:      ui.PlainSprintf("Connection failed: %v", err),
 39 	})
 40 	return nil
 41@@ -467,93 +465,89 @@ func (app *App) uiLoop() {
 42 }
 43 
 44 func (app *App) handleUIEvent(ev interface{}) bool {
 45+	// TODO: eat QuitEvent here?
 46 	switch ev := ev.(type) {
 47-	case *tcell.EventResize:
 48+	case vaxis.Resize:
 49 		app.win.Resize()
 50-	case *tcell.EventPaste:
 51-		app.pasting = ev.Start()
 52-	case *tcell.EventMouse:
 53+	case vaxis.PasteStartEvent:
 54+		app.pasting = true
 55+	case vaxis.PasteEndEvent:
 56+		app.pasting = false
 57+	case vaxis.Mouse:
 58 		app.handleMouseEvent(ev)
 59-	case *tcell.EventKey:
 60+	case vaxis.Key:
 61 		app.handleKeyEvent(ev)
 62-	case tcell.EventInterrupt:
 63-		// unknown key event, ignore
 64-		return true
 65-	case *tcell.EventError:
 66-		// happens when the terminal is closing: in which case, exit
 67-		return false
 68-	case tcell.EventVaxis:
 69-		// TODO use vaxis events
 70-		return true
 71 	case *ui.NotifyEvent:
 72 		app.win.JumpBufferNetwork(ev.NetID, ev.Buffer)
 73 	case statusLine:
 74 		app.addStatusLine(ev.netID, ev.line)
 75 	default:
 76-		panic("unreachable")
 77+		// TODO: missing event types
 78 	}
 79 	return true
 80 }
 81 
 82-func (app *App) handleMouseEvent(ev *tcell.EventMouse) {
 83-	x, y := ev.Position()
 84+func (app *App) handleMouseEvent(ev vaxis.Mouse) {
 85+	x, y := ev.Col, ev.Row
 86 	w, h := app.win.Size()
 87-	if ev.Buttons()&tcell.WheelUp != 0 {
 88-		if x < app.win.ChannelWidth() || (app.win.ChannelWidth() == 0 && y == h-1) {
 89-			app.win.ScrollChannelUpBy(4)
 90-		} else if x > w-app.win.MemberWidth() {
 91-			app.win.ScrollMemberUpBy(4)
 92-		} else {
 93-			app.win.ScrollUpBy(4)
 94-			app.requestHistory()
 95-		}
 96-	}
 97-	if ev.Buttons()&tcell.WheelDown != 0 {
 98-		if x < app.win.ChannelWidth() || (app.win.ChannelWidth() == 0 && y == h-1) {
 99-			app.win.ScrollChannelDownBy(4)
100-		} else if x > w-app.win.MemberWidth() {
101-			app.win.ScrollMemberDownBy(4)
102-		} else {
103-			app.win.ScrollDownBy(4)
104+	if ev.EventType == vaxis.EventPress {
105+		if ev.Button == vaxis.MouseWheelUp {
106+			if x < app.win.ChannelWidth() || (app.win.ChannelWidth() == 0 && y == h-1) {
107+				app.win.ScrollChannelUpBy(4)
108+			} else if x > w-app.win.MemberWidth() {
109+				app.win.ScrollMemberUpBy(4)
110+			} else {
111+				app.win.ScrollUpBy(4)
112+				app.requestHistory()
113+			}
114 		}
115-	}
116-	if ev.Buttons()&tcell.ButtonPrimary != 0 {
117-		if app.win.ChannelColClicked() {
118-			app.win.ResizeChannelCol(x + 1)
119-		} else if app.win.MemberColClicked() {
120-			app.win.ResizeMemberCol(w - x)
121-		} else if x == app.win.ChannelWidth()-1 {
122-			app.win.ClickChannelCol(true)
123-			app.win.SetMouseShape(vaxis.MouseShapeResizeHorizontal)
124-		} else if x < app.win.ChannelWidth() {
125-			app.win.ClickBuffer(y + app.win.ChannelOffset())
126-		} else if app.win.ChannelWidth() == 0 && y == h-1 {
127-			app.win.ClickBuffer(app.win.HorizontalBufferOffset(x))
128-		} else if x == w-app.win.MemberWidth() {
129-			app.win.ClickMemberCol(true)
130-			app.win.SetMouseShape(vaxis.MouseShapeResizeHorizontal)
131-		} else if x > w-app.win.MemberWidth() && y >= 2 {
132-			app.win.ClickMember(y - 2 + app.win.MemberOffset())
133+		if ev.Button == vaxis.MouseWheelDown {
134+			if x < app.win.ChannelWidth() || (app.win.ChannelWidth() == 0 && y == h-1) {
135+				app.win.ScrollChannelDownBy(4)
136+			} else if x > w-app.win.MemberWidth() {
137+				app.win.ScrollMemberDownBy(4)
138+			} else {
139+				app.win.ScrollDownBy(4)
140+			}
141 		}
142-	}
143-	if ev.Buttons()&tcell.ButtonMiddle != 0 {
144-		i := -1
145-		if x < app.win.ChannelWidth() {
146-			i = y + app.win.ChannelOffset()
147-		} else if app.win.ChannelWidth() == 0 && y == h-1 {
148-			i = app.win.HorizontalBufferOffset(x)
149+		if ev.Button == vaxis.MouseLeftButton {
150+			if app.win.ChannelColClicked() {
151+				app.win.ResizeChannelCol(x + 1)
152+			} else if app.win.MemberColClicked() {
153+				app.win.ResizeMemberCol(w - x)
154+			} else if x == app.win.ChannelWidth()-1 {
155+				app.win.ClickChannelCol(true)
156+				app.win.SetMouseShape(vaxis.MouseShapeResizeHorizontal)
157+			} else if x < app.win.ChannelWidth() {
158+				app.win.ClickBuffer(y + app.win.ChannelOffset())
159+			} else if app.win.ChannelWidth() == 0 && y == h-1 {
160+				app.win.ClickBuffer(app.win.HorizontalBufferOffset(x))
161+			} else if x == w-app.win.MemberWidth() {
162+				app.win.ClickMemberCol(true)
163+				app.win.SetMouseShape(vaxis.MouseShapeResizeHorizontal)
164+			} else if x > w-app.win.MemberWidth() && y >= 2 {
165+				app.win.ClickMember(y - 2 + app.win.MemberOffset())
166+			}
167 		}
168-		netID, channel, ok := app.win.Buffer(i)
169-		if ok && channel != "" {
170-			s := app.sessions[netID]
171-			if s != nil && s.IsChannel(channel) {
172-				s.Part(channel, "")
173-			} else {
174-				app.win.RemoveBuffer(netID, channel)
175+		if ev.Button == vaxis.MouseMiddleButton {
176+			i := -1
177+			if x < app.win.ChannelWidth() {
178+				i = y + app.win.ChannelOffset()
179+			} else if app.win.ChannelWidth() == 0 && y == h-1 {
180+				i = app.win.HorizontalBufferOffset(x)
181+			}
182+			netID, channel, ok := app.win.Buffer(i)
183+			if ok && channel != "" {
184+				s := app.sessions[netID]
185+				if s != nil && s.IsChannel(channel) {
186+					s.Part(channel, "")
187+				} else {
188+					app.win.RemoveBuffer(netID, channel)
189+				}
190 			}
191 		}
192 	}
193-	if ev.Buttons() == 0 {
194+	if ev.EventType == vaxis.EventRelease {
195 		if x < app.win.ChannelWidth()-1 {
196 			if i := y + app.win.ChannelOffset(); i == app.win.ClickedBuffer() {
197 				app.win.GoToBufferNo(i)
198@@ -572,7 +566,7 @@ func (app *App) handleMouseEvent(ev *tcell.EventMouse) {
199 							app.win.AddLine(netID, target, ui.Line{
200 								At:        time.Now(),
201 								Head:      "--",
202-								HeadColor: tcell.ColorRed,
203+								HeadColor: ui.ColorRed,
204 								Body:      ui.PlainSprintf("Adding networks is not available: %v", err),
205 							})
206 						} else {
207@@ -633,156 +627,157 @@ func (app *App) handleMouseEvent(ev *tcell.EventMouse) {
208 	}
209 }
210 
211-func (app *App) handleKeyEvent(ev *tcell.EventKey) {
212-	switch ev.Key() {
213-	case tcell.KeyCtrlC:
214+func (app *App) handleKeyEvent(ev vaxis.Key) {
215+	switch ev.EventType {
216+	case vaxis.EventPress, vaxis.EventRepeat, vaxis.EventPaste:
217+	default:
218+		return
219+	}
220+	if ev.Text != "" {
221+		for _, r := range ev.Text {
222+			app.win.InputRune(r)
223+		}
224+		app.typing()
225+		return
226+	}
227+
228+	if keyMatches(ev, 'c', vaxis.ModCtrl) {
229 		if app.win.InputClear() {
230 			app.typing()
231 		} else {
232 			app.win.InputSet("/quit")
233 		}
234-	case tcell.KeyCtrlF:
235+	} else if keyMatches(ev, 'f', vaxis.ModCtrl) {
236 		if len(app.win.InputContent()) == 0 {
237 			app.win.InputSet("/search ")
238 		}
239-	case tcell.KeyCtrlA:
240+	} else if keyMatches(ev, 'a', vaxis.ModCtrl) {
241 		app.win.InputHome()
242-	case tcell.KeyCtrlE:
243+	} else if keyMatches(ev, 'e', vaxis.ModCtrl) {
244 		app.win.InputEnd()
245-	case tcell.KeyCtrlL:
246+	} else if keyMatches(ev, 'l', vaxis.ModCtrl) {
247 		app.win.Resize()
248-	case tcell.KeyCtrlU, tcell.KeyPgUp:
249+	} else if keyMatches(ev, 'u', vaxis.ModCtrl) || keyMatches(ev, vaxis.KeyPgUp, 0) {
250 		app.win.ScrollUp()
251 		app.requestHistory()
252-	case tcell.KeyCtrlD, tcell.KeyPgDn:
253+	} else if keyMatches(ev, 'd', vaxis.ModCtrl) || keyMatches(ev, vaxis.KeyPgDown, 0) {
254 		app.win.ScrollDown()
255-	case tcell.KeyCtrlN:
256+	} else if keyMatches(ev, 'n', vaxis.ModCtrl) {
257 		app.win.NextBuffer()
258 		app.win.ScrollToBuffer()
259-	case tcell.KeyCtrlP:
260+	} else if keyMatches(ev, 'p', vaxis.ModCtrl) {
261 		app.win.PreviousBuffer()
262 		app.win.ScrollToBuffer()
263-	case tcell.KeyRight:
264-		if ev.Modifiers() == tcell.ModAlt {
265-			app.win.NextBuffer()
266-			app.win.ScrollToBuffer()
267-		} else if ev.Modifiers() == tcell.ModShift {
268-			app.win.NextUnreadBuffer()
269-			app.win.ScrollToBuffer()
270-		} else if ev.Modifiers() == tcell.ModCtrl {
271-			app.win.InputRightWord()
272-		} else {
273-			app.win.InputRight()
274-		}
275-	case tcell.KeyLeft:
276-		if ev.Modifiers() == tcell.ModAlt {
277-			app.win.PreviousBuffer()
278-			app.win.ScrollToBuffer()
279-		} else if ev.Modifiers() == tcell.ModShift {
280-			app.win.PreviousUnreadBuffer()
281-			app.win.ScrollToBuffer()
282-		} else if ev.Modifiers() == tcell.ModCtrl {
283-			app.win.InputLeftWord()
284-		} else {
285-			app.win.InputLeft()
286-		}
287-	case tcell.KeyUp:
288-		if ev.Modifiers() == tcell.ModAlt {
289-			app.win.PreviousBuffer()
290-		} else {
291-			app.win.InputUp()
292-		}
293-	case tcell.KeyDown:
294-		if ev.Modifiers() == tcell.ModAlt {
295-			app.win.NextBuffer()
296-		} else {
297-			app.win.InputDown()
298-		}
299-	case tcell.KeyHome:
300-		if ev.Modifiers() == tcell.ModAlt {
301-			app.win.GoToBufferNo(0)
302-		} else {
303-			app.win.InputHome()
304-		}
305-	case tcell.KeyEnd:
306-		if ev.Modifiers() == tcell.ModAlt {
307-			maxInt := int(^uint(0) >> 1)
308-			app.win.GoToBufferNo(maxInt)
309-		} else {
310-			app.win.InputEnd()
311-		}
312-	case tcell.KeyBackspace, tcell.KeyBackspace2:
313-		var ok bool
314-		if ev.Modifiers() == tcell.ModAlt {
315-			ok = app.win.InputDeleteWord()
316-		} else {
317-			ok = app.win.InputBackspace()
318+	} else if keyMatches(ev, vaxis.KeyRight, vaxis.ModAlt) {
319+		app.win.NextBuffer()
320+		app.win.ScrollToBuffer()
321+	} else if keyMatches(ev, vaxis.KeyRight, vaxis.ModShift) {
322+		app.win.NextUnreadBuffer()
323+		app.win.ScrollToBuffer()
324+	} else if keyMatches(ev, vaxis.KeyRight, vaxis.ModCtrl) {
325+		app.win.InputRightWord()
326+	} else if keyMatches(ev, vaxis.KeyRight, 0) {
327+		app.win.InputRight()
328+	} else if keyMatches(ev, vaxis.KeyLeft, vaxis.ModAlt) {
329+		app.win.PreviousBuffer()
330+		app.win.ScrollToBuffer()
331+	} else if keyMatches(ev, vaxis.KeyLeft, vaxis.ModShift) {
332+		app.win.PreviousUnreadBuffer()
333+		app.win.ScrollToBuffer()
334+	} else if keyMatches(ev, vaxis.KeyLeft, vaxis.ModCtrl) {
335+		app.win.InputLeftWord()
336+	} else if keyMatches(ev, vaxis.KeyLeft, 0) {
337+		app.win.InputLeft()
338+	} else if keyMatches(ev, vaxis.KeyUp, vaxis.ModAlt) {
339+		app.win.PreviousBuffer()
340+	} else if keyMatches(ev, vaxis.KeyUp, 0) {
341+		app.win.InputUp()
342+	} else if keyMatches(ev, vaxis.KeyDown, vaxis.ModAlt) {
343+		app.win.NextBuffer()
344+	} else if keyMatches(ev, vaxis.KeyDown, 0) {
345+		app.win.InputDown()
346+	} else if keyMatches(ev, vaxis.KeyHome, vaxis.ModAlt) {
347+		app.win.GoToBufferNo(0)
348+	} else if keyMatches(ev, vaxis.KeyHome, 0) {
349+		app.win.InputHome()
350+	} else if keyMatches(ev, vaxis.KeyEnd, vaxis.ModAlt) {
351+		maxInt := int(^uint(0) >> 1)
352+		app.win.GoToBufferNo(maxInt)
353+	} else if keyMatches(ev, vaxis.KeyEnd, 0) {
354+		app.win.InputEnd()
355+	} else if keyMatches(ev, vaxis.KeyBackspace, vaxis.ModAlt) {
356+		if app.win.InputDeleteWord() {
357+			app.typing()
358 		}
359-		if ok {
360+	} else if keyMatches(ev, vaxis.KeyBackspace, 0) {
361+		if app.win.InputBackspace() {
362 			app.typing()
363 		}
364-	case tcell.KeyDelete:
365-		ok := app.win.InputDelete()
366-		if ok {
367+	} else if keyMatches(ev, vaxis.KeyDelete, 0) {
368+		if app.win.InputDelete() {
369 			app.typing()
370 		}
371-	case tcell.KeyCtrlW:
372-		ok := app.win.InputDeleteWord()
373-		if ok {
374+	} else if keyMatches(ev, 'w', vaxis.ModCtrl) {
375+		if app.win.InputDeleteWord() {
376 			app.typing()
377 		}
378-	case tcell.KeyCtrlR:
379+	} else if keyMatches(ev, 'r', vaxis.ModCtrl) {
380 		app.win.InputBackSearch()
381-	case tcell.KeyTab:
382-		ok := app.win.InputAutoComplete()
383-		if ok {
384+	} else if keyMatches(ev, vaxis.KeyTab, 0) {
385+		if app.win.InputAutoComplete() {
386 			app.typing()
387 		}
388-	case tcell.KeyEscape:
389+	} else if keyMatches(ev, vaxis.KeyEsc, 0) {
390 		app.win.CloseOverlay()
391-	case tcell.KeyF7:
392+	} else if keyMatches(ev, vaxis.KeyF07, 0) {
393 		app.win.ToggleChannelList()
394-	case tcell.KeyF8:
395+	} else if keyMatches(ev, vaxis.KeyF08, 0) {
396 		app.win.ToggleMemberList()
397-	case tcell.KeyCR, tcell.KeyLF:
398-		if app.pasting {
399+	} else if keyMatches(ev, '\n', 0) || keyMatches(ev, '\r', 0) || keyMatches(ev, 'j', vaxis.ModCtrl) || keyMatches(ev, vaxis.KeyKeyPadEnter, 0) {
400+		if ev.EventType == vaxis.EventPaste {
401 			app.win.InputRune('\n')
402-			break
403-		}
404-		netID, buffer := app.win.CurrentBuffer()
405-		input := string(app.win.InputContent())
406-		var err error
407-		for _, part := range strings.Split(input, "\n") {
408-			if err = app.handleInput(buffer, part); err != nil {
409-				app.win.AddLine(netID, buffer, ui.Line{
410-					At:        time.Now(),
411-					Head:      "!!",
412-					HeadColor: tcell.ColorRed,
413-					Notify:    ui.NotifyUnread,
414-					Body:      ui.PlainSprintf("%q: %s", input, err),
415-				})
416-				break
417+		} else {
418+			netID, buffer := app.win.CurrentBuffer()
419+			input := string(app.win.InputContent())
420+			var err error
421+			for _, part := range strings.Split(input, "\n") {
422+				if err = app.handleInput(buffer, part); err != nil {
423+					app.win.AddLine(netID, buffer, ui.Line{
424+						At:        time.Now(),
425+						Head:      "!!",
426+						HeadColor: ui.ColorRed,
427+						Notify:    ui.NotifyUnread,
428+						Body:      ui.PlainSprintf("%q: %s", input, err),
429+					})
430+					break
431+				}
432 			}
433-		}
434-		if err == nil {
435-			app.win.InputFlush()
436-		}
437-	case tcell.KeyRune:
438-		if ev.Modifiers() == tcell.ModAlt {
439-			switch ev.Rune() {
440-			case 'n':
441-				app.win.ScrollDownHighlight()
442-			case 'p':
443-				app.win.ScrollUpHighlight()
444-			case '1', '2', '3', '4', '5', '6', '7', '8', '9':
445-				app.win.GoToBufferNo(int(ev.Rune()-'0') - 1)
446+			if err == nil {
447+				app.win.InputFlush()
448 			}
449-		} else {
450-			app.win.InputRune(ev.Rune())
451-			app.typing()
452 		}
453-	default:
454-		return
455+	} else if keyMatches(ev, 'n', vaxis.ModAlt) {
456+		app.win.ScrollDownHighlight()
457+	} else if keyMatches(ev, 'p', vaxis.ModAlt) {
458+		app.win.ScrollUpHighlight()
459+	} else if keyMatches(ev, '1', vaxis.ModAlt) || keyMatches(ev, vaxis.KeyKeyPad1, vaxis.ModAlt) {
460+		app.win.GoToBufferNo(0)
461+	} else if keyMatches(ev, '2', vaxis.ModAlt) || keyMatches(ev, vaxis.KeyKeyPad2, vaxis.ModAlt) {
462+		app.win.GoToBufferNo(1)
463+	} else if keyMatches(ev, '3', vaxis.ModAlt) || keyMatches(ev, vaxis.KeyKeyPad3, vaxis.ModAlt) {
464+		app.win.GoToBufferNo(2)
465+	} else if keyMatches(ev, '4', vaxis.ModAlt) || keyMatches(ev, vaxis.KeyKeyPad4, vaxis.ModAlt) {
466+		app.win.GoToBufferNo(3)
467+	} else if keyMatches(ev, '5', vaxis.ModAlt) || keyMatches(ev, vaxis.KeyKeyPad5, vaxis.ModAlt) {
468+		app.win.GoToBufferNo(4)
469+	} else if keyMatches(ev, '6', vaxis.ModAlt) || keyMatches(ev, vaxis.KeyKeyPad6, vaxis.ModAlt) {
470+		app.win.GoToBufferNo(5)
471+	} else if keyMatches(ev, '7', vaxis.ModAlt) || keyMatches(ev, vaxis.KeyKeyPad7, vaxis.ModAlt) {
472+		app.win.GoToBufferNo(6)
473+	} else if keyMatches(ev, '8', vaxis.ModAlt) || keyMatches(ev, vaxis.KeyKeyPad8, vaxis.ModAlt) {
474+		app.win.GoToBufferNo(7)
475+	} else if keyMatches(ev, '9', vaxis.ModAlt) || keyMatches(ev, vaxis.KeyKeyPad9, vaxis.ModAlt) {
476+		app.win.GoToBufferNo(8)
477 	}
478 }
479 
480@@ -853,7 +848,7 @@ func (app *App) handleIRCEvent(netID string, ev interface{}) {
481 	if err != nil {
482 		app.win.AddLine(netID, "", ui.Line{
483 			Head:      "!!",
484-			HeadColor: tcell.ColorRed,
485+			HeadColor: ui.ColorRed,
486 			Notify:    ui.NotifyUnread,
487 			Body:      ui.PlainSprintf("Received corrupt message %q: %s", msg.String(), err),
488 		})
489@@ -894,8 +889,10 @@ func (app *App) handleIRCEvent(netID string, ev interface{}) {
490 		}
491 		var body ui.StyledStringBuilder
492 		body.WriteString(fmt.Sprintf("%s\u2192%s", ev.FormerNick, s.Nick()))
493-		textStyle := tcell.StyleDefault.Foreground(app.cfg.Colors.Status)
494-		arrowStyle := tcell.StyleDefault
495+		textStyle := vaxis.Style{
496+			Foreground: app.cfg.Colors.Status,
497+		}
498+		var arrowStyle vaxis.Style
499 		body.AddStyle(0, textStyle)
500 		body.AddStyle(len(ev.FormerNick), arrowStyle)
501 		body.AddStyle(body.Len()-len(s.Nick()), textStyle)
502@@ -1007,7 +1004,9 @@ func (app *App) handleIRCEvent(netID string, ev interface{}) {
503 			Head:      "--",
504 			HeadColor: app.cfg.Colors.Status,
505 			Notify:    notify,
506-			Body:      ui.Styled(body, tcell.StyleDefault.Foreground(app.cfg.Colors.Status)),
507+			Body: ui.Styled(body, vaxis.Style{
508+				Foreground: app.cfg.Colors.Status,
509+			}),
510 			Highlight: notify == ui.NotifyHighlight,
511 			Readable:  true,
512 		})
513@@ -1156,7 +1155,9 @@ func (app *App) handleIRCEvent(netID string, ev interface{}) {
514 			At:        msg.TimeOrNow(),
515 			Head:      head,
516 			HeadColor: app.cfg.Colors.Status,
517-			Body:      ui.Styled(ev.Message, tcell.StyleDefault.Foreground(app.cfg.Colors.Status)),
518+			Body: ui.Styled(ev.Message, vaxis.Style{
519+				Foreground: app.cfg.Colors.Status,
520+			}),
521 		})
522 		return
523 	case irc.ErrorEvent:
524@@ -1168,7 +1169,9 @@ func (app *App) handleIRCEvent(netID string, ev interface{}) {
525 				At:        msg.TimeOrNow(),
526 				Head:      fmt.Sprintf("(%s) --", ev.Code),
527 				HeadColor: app.cfg.Colors.Status,
528-				Body:      ui.Styled(ev.Message, tcell.StyleDefault.Foreground(app.cfg.Colors.Status)),
529+				Body: ui.Styled(ev.Message, vaxis.Style{
530+					Foreground: app.cfg.Colors.Status,
531+				}),
532 			})
533 			return
534 		case irc.SeverityFail:
535@@ -1257,7 +1260,7 @@ func (app *App) notifyHighlight(buffer, nick, content string, current bool) {
536 			app.addStatusLine(netID, ui.Line{
537 				At:        time.Now(),
538 				Head:      "!!",
539-				HeadColor: tcell.ColorRed,
540+				HeadColor: ui.ColorRed,
541 				Body:      ui.PlainString(body),
542 			})
543 		}
544@@ -1280,7 +1283,7 @@ func (app *App) notifyHighlight(buffer, nick, content string, current bool) {
545 		app.addStatusLine(netID, ui.Line{
546 			At:        time.Now(),
547 			Head:      "!!",
548-			HeadColor: tcell.ColorRed,
549+			HeadColor: ui.ColorRed,
550 			Body:      ui.PlainString(body),
551 		})
552 	}
553@@ -1335,8 +1338,10 @@ func (app *App) formatEvent(ev irc.Event) ui.Line {
554 	case irc.UserNickEvent:
555 		var body ui.StyledStringBuilder
556 		body.WriteString(fmt.Sprintf("%s\u2192%s", ev.FormerNick, ev.User))
557-		textStyle := tcell.StyleDefault.Foreground(app.cfg.Colors.Status)
558-		arrowStyle := tcell.StyleDefault
559+		textStyle := vaxis.Style{
560+			Foreground: app.cfg.Colors.Status,
561+		}
562+		var arrowStyle vaxis.Style
563 		body.AddStyle(0, textStyle)
564 		body.AddStyle(len(ev.FormerNick), arrowStyle)
565 		body.AddStyle(body.Len()-len(ev.User), textStyle)
566@@ -1352,9 +1357,13 @@ func (app *App) formatEvent(ev irc.Event) ui.Line {
567 	case irc.UserJoinEvent:
568 		var body ui.StyledStringBuilder
569 		body.Grow(len(ev.User) + 1)
570-		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGreen))
571+		body.SetStyle(vaxis.Style{
572+			Foreground: vaxis.IndexColor(2),
573+		})
574 		body.WriteByte('+')
575-		body.SetStyle(tcell.StyleDefault.Foreground(app.cfg.Colors.Status))
576+		body.SetStyle(vaxis.Style{
577+			Foreground: app.cfg.Colors.Status,
578+		})
579 		body.WriteString(ev.User)
580 		return ui.Line{
581 			At:        ev.Time,
582@@ -1368,9 +1377,13 @@ func (app *App) formatEvent(ev irc.Event) ui.Line {
583 	case irc.UserPartEvent:
584 		var body ui.StyledStringBuilder
585 		body.Grow(len(ev.User) + 1)
586-		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorRed))
587+		body.SetStyle(vaxis.Style{
588+			Foreground: ui.ColorRed,
589+		})
590 		body.WriteByte('-')
591-		body.SetStyle(tcell.StyleDefault.Foreground(app.cfg.Colors.Status))
592+		body.SetStyle(vaxis.Style{
593+			Foreground: app.cfg.Colors.Status,
594+		})
595 		body.WriteString(ev.User)
596 		return ui.Line{
597 			At:        ev.Time,
598@@ -1384,9 +1397,13 @@ func (app *App) formatEvent(ev irc.Event) ui.Line {
599 	case irc.UserQuitEvent:
600 		var body ui.StyledStringBuilder
601 		body.Grow(len(ev.User) + 1)
602-		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorRed))
603+		body.SetStyle(vaxis.Style{
604+			Foreground: ui.ColorRed,
605+		})
606 		body.WriteByte('-')
607-		body.SetStyle(tcell.StyleDefault.Foreground(app.cfg.Colors.Status))
608+		body.SetStyle(vaxis.Style{
609+			Foreground: app.cfg.Colors.Status,
610+		})
611 		body.WriteString(ev.User)
612 		return ui.Line{
613 			At:        ev.Time,
614@@ -1406,8 +1423,10 @@ func (app *App) formatEvent(ev irc.Event) ui.Line {
615 			Head:      "--",
616 			HeadColor: app.cfg.Colors.Status,
617 			Notify:    ui.NotifyUnread,
618-			Body:      ui.Styled(body, tcell.StyleDefault.Foreground(app.cfg.Colors.Status)),
619-			Readable:  true,
620+			Body: ui.Styled(body, vaxis.Style{
621+				Foreground: app.cfg.Colors.Status,
622+			}),
623+			Readable: true,
624 		}
625 	case irc.ModeChangeEvent:
626 		body := fmt.Sprintf("[%s]", ev.Mode)
627@@ -1417,7 +1436,9 @@ func (app *App) formatEvent(ev irc.Event) ui.Line {
628 			At:        ev.Time,
629 			Head:      "--",
630 			HeadColor: app.cfg.Colors.Status,
631-			Body:      ui.Styled(body, tcell.StyleDefault.Foreground(app.cfg.Colors.Status)),
632+			Body: ui.Styled(body, vaxis.Style{
633+				Foreground: app.cfg.Colors.Status,
634+			}),
635 			Mergeable: mergeable,
636 			Data:      []irc.Event{ev},
637 			Readable:  true,
638@@ -1479,7 +1500,7 @@ func (app *App) formatMessage(s *irc.Session, ev irc.MessageEvent) (buffer strin
639 	}
640 
641 	head := ev.User
642-	headColor := tcell.ColorWhite
643+	headColor := vaxis.IndexColor(15)
644 	if isAction || isNotice {
645 		head = "*"
646 	} else {
647@@ -1489,16 +1510,20 @@ func (app *App) formatMessage(s *irc.Session, ev irc.MessageEvent) (buffer strin
648 	var body ui.StyledStringBuilder
649 	if isNotice {
650 		color := ui.IdentColor(app.cfg.Colors.Nicks, ev.User, isFromSelf)
651-		body.SetStyle(tcell.StyleDefault.Foreground(color))
652+		body.SetStyle(vaxis.Style{
653+			Foreground: color,
654+		})
655 		body.WriteString(ev.User)
656-		body.SetStyle(tcell.StyleDefault)
657+		body.SetStyle(vaxis.Style{})
658 		body.WriteString(": ")
659 		body.WriteStyledString(ui.IRCString(content))
660 	} else if isAction {
661 		color := ui.IdentColor(app.cfg.Colors.Nicks, ev.User, isFromSelf)
662-		body.SetStyle(tcell.StyleDefault.Foreground(color))
663+		body.SetStyle(vaxis.Style{
664+			Foreground: color,
665+		})
666 		body.WriteString(ev.User)
667-		body.SetStyle(tcell.StyleDefault)
668+		body.SetStyle(vaxis.Style{})
669 		body.WriteString(" ")
670 		body.WriteStyledString(ui.IRCString(content))
671 	} else {
672@@ -1625,16 +1650,14 @@ func (app *App) updatePrompt() {
673 	command := isCommand(app.win.InputContent())
674 	var prompt ui.StyledString
675 	if buffer == "" || command {
676-		prompt = ui.Styled(">",
677-			tcell.
678-				StyleDefault.
679-				Foreground(app.cfg.Colors.Prompt),
680+		prompt = ui.Styled(">", vaxis.Style{
681+			Foreground: app.cfg.Colors.Prompt,
682+		},
683 		)
684 	} else if s == nil {
685-		prompt = ui.Styled("<offline>",
686-			tcell.
687-				StyleDefault.
688-				Foreground(tcell.ColorRed),
689+		prompt = ui.Styled("<offline>", vaxis.Style{
690+			Foreground: ui.ColorRed,
691+		},
692 		)
693 	} else {
694 		prompt = ui.IdentString(app.cfg.Colors.Nicks, s.Nick(), true)
695@@ -1659,7 +1682,28 @@ func (app *App) printTopic(netID, buffer string) (ok bool) {
696 		At:        time.Now(),
697 		Head:      "--",
698 		HeadColor: app.cfg.Colors.Status,
699-		Body:      ui.Styled(body, tcell.StyleDefault.Foreground(app.cfg.Colors.Status)),
700+		Body: ui.Styled(body, vaxis.Style{
701+			Foreground: app.cfg.Colors.Status,
702+		}),
703 	})
704 	return true
705 }
706+
707+func keyMatches(k vaxis.Key, r rune, mods vaxis.ModifierMask) bool {
708+	m := k.Modifiers
709+	m &^= vaxis.ModCapsLock
710+	m &^= vaxis.ModNumLock
711+	if k.Keycode == r && mods == m {
712+		// ctrl+a and user pressed ctrl+a
713+		// ctrl+. and user pressed ctrl+. on a US keyboard
714+		return true
715+	}
716+	if m&vaxis.ModShift != 0 {
717+		m &^= vaxis.ModShift
718+		if k.ShiftedCode == r && mods == m {
719+			// ctrl+. and user pressed ctrl+shift+; on a French keyboard
720+			return true
721+		}
722+	}
723+	return false
724+}
+0, -4
 1@@ -12,14 +12,10 @@ import (
 2 	"syscall"
 3 	"time"
 4 
 5-	"github.com/gdamore/tcell/v2"
 6-
 7 	"git.sr.ht/~delthas/senpai"
 8 )
 9 
10 func main() {
11-	tcell.SetEncodingFallback(tcell.EncodingFallbackASCII)
12-
13 	var configPath string
14 	var nickname string
15 	var debug bool
+14, -6
 1@@ -9,8 +9,8 @@ import (
 2 	"strings"
 3 	"time"
 4 
 5+	"git.sr.ht/~rockorager/vaxis"
 6 	"github.com/delthas/go-libnp"
 7-	"github.com/gdamore/tcell/v2"
 8 	"golang.org/x/net/context"
 9 
10 	"git.sr.ht/~delthas/senpai/irc"
11@@ -369,9 +369,11 @@ func commandDoHelp(app *App, args []string) (err error) {
12 	addLineCommand := func(sb *ui.StyledStringBuilder, name string, cmd *command) {
13 		sb.Reset()
14 		sb.Grow(len(name) + 1 + len(cmd.Usage))
15-		sb.SetStyle(tcell.StyleDefault.Bold(true))
16+		sb.SetStyle(vaxis.Style{
17+			Attribute: vaxis.AttrBold,
18+		})
19 		sb.WriteString(name)
20-		sb.SetStyle(tcell.StyleDefault)
21+		sb.SetStyle(vaxis.Style{})
22 		sb.WriteByte(' ')
23 		sb.WriteString(cmd.Usage)
24 		app.win.AddLine(netID, buffer, ui.Line{
25@@ -498,13 +500,19 @@ func commandDoNames(app *App, args []string) (err error) {
26 		return fmt.Errorf("this is not a channel")
27 	}
28 	var sb ui.StyledStringBuilder
29-	sb.SetStyle(tcell.StyleDefault.Foreground(app.cfg.Colors.Status))
30+	sb.SetStyle(vaxis.Style{
31+		Foreground: app.cfg.Colors.Status,
32+	})
33 	sb.WriteString("Names: ")
34 	for _, name := range s.Names(buffer) {
35 		if name.PowerLevel != "" {
36-			sb.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGreen))
37+			sb.SetStyle(vaxis.Style{
38+				Foreground: vaxis.IndexColor(2),
39+			})
40 			sb.WriteString(name.PowerLevel)
41-			sb.SetStyle(tcell.StyleDefault.Foreground(app.cfg.Colors.Status))
42+			sb.SetStyle(vaxis.Style{
43+				Foreground: app.cfg.Colors.Status,
44+			})
45 		}
46 		sb.WriteString(name.Name.Name)
47 		sb.WriteByte(' ')
+28, -28
  1@@ -9,57 +9,57 @@ import (
  2 	"strconv"
  3 	"strings"
  4 
  5-	"git.sr.ht/~delthas/senpai/ui"
  6+	"git.sr.ht/~rockorager/vaxis"
  7 
  8-	"github.com/gdamore/tcell/v2"
  9+	"git.sr.ht/~delthas/senpai/ui"
 10 
 11 	"git.sr.ht/~emersion/go-scfg"
 12 )
 13 
 14-func parseColor(s string, c *tcell.Color) error {
 15+func parseColor(s string, c *vaxis.Color) error {
 16 	if strings.HasPrefix(s, "#") {
 17 		hex, err := strconv.ParseInt(s[1:], 16, 32)
 18 		if err != nil {
 19 			return err
 20 		}
 21 
 22-		*c = tcell.NewHexColor(int32(hex))
 23+		*c = vaxis.HexColor(uint32(hex))
 24 		return nil
 25 	}
 26 	ok := true
 27 	switch s {
 28 	case "black":
 29-		*c = tcell.ColorBlack
 30+		*c = vaxis.IndexColor(0)
 31 	case "maroon":
 32-		*c = tcell.ColorBrown
 33+		*c = vaxis.IndexColor(1)
 34 	case "green":
 35-		*c = tcell.ColorGreen
 36+		*c = vaxis.IndexColor(2)
 37 	case "olive":
 38-		*c = tcell.ColorOlive
 39+		*c = vaxis.IndexColor(3)
 40 	case "navy":
 41-		*c = tcell.ColorNavy
 42+		*c = vaxis.IndexColor(4)
 43 	case "purple":
 44-		*c = tcell.ColorPurple
 45+		*c = vaxis.IndexColor(5)
 46 	case "teal":
 47-		*c = tcell.ColorTeal
 48+		*c = vaxis.IndexColor(6)
 49 	case "silver":
 50-		*c = tcell.ColorSilver
 51+		*c = vaxis.IndexColor(7)
 52 	case "gray", "grey":
 53-		*c = tcell.ColorGray
 54+		*c = vaxis.IndexColor(8)
 55 	case "red":
 56-		*c = tcell.ColorRed
 57+		*c = vaxis.IndexColor(9)
 58 	case "lime":
 59-		*c = tcell.ColorLime
 60+		*c = vaxis.IndexColor(10)
 61 	case "yellow":
 62-		*c = tcell.ColorYellow
 63+		*c = vaxis.IndexColor(11)
 64 	case "blue":
 65-		*c = tcell.ColorBlue
 66+		*c = vaxis.IndexColor(12)
 67 	case "fuschia":
 68-		*c = tcell.ColorFuchsia
 69+		*c = vaxis.IndexColor(13)
 70 	case "aqua":
 71-		*c = tcell.ColorAqua
 72+		*c = vaxis.IndexColor(14)
 73 	case "white":
 74-		*c = tcell.ColorWhite
 75+		*c = vaxis.IndexColor(15)
 76 	default:
 77 		ok = false
 78 	}
 79@@ -73,7 +73,7 @@ func parseColor(s string, c *tcell.Color) error {
 80 	}
 81 
 82 	if code == -1 {
 83-		*c = tcell.ColorDefault
 84+		*c = vaxis.Color(0)
 85 		return nil
 86 	}
 87 
 88@@ -81,7 +81,7 @@ func parseColor(s string, c *tcell.Color) error {
 89 		return fmt.Errorf("color code must be between 0-255. If you meant to use true colors, use #aabbcc notation")
 90 	}
 91 
 92-	*c = tcell.PaletteColor(code)
 93+	*c = vaxis.IndexColor(uint8(code))
 94 
 95 	return nil
 96 }
 97@@ -149,13 +149,13 @@ func Defaults() Config {
 98 		TextMaxWidth:     0,
 99 		StatusEnabled:    true,
100 		Colors: ui.ConfigColors{
101-			Status: tcell.ColorGray,
102-			Prompt: tcell.ColorDefault,
103-			Unread: tcell.ColorDefault,
104+			Status: ui.ColorGray,
105+			Prompt: vaxis.Color(0),
106+			Unread: vaxis.Color(0),
107 			Nicks: ui.ColorScheme{
108 				Type:   ui.ColorSchemeBase,
109-				Others: tcell.ColorDefault,
110-				Self:   tcell.ColorRed,
111+				Others: vaxis.Color(0),
112+				Self:   vaxis.Color(9),
113 			},
114 		},
115 		Debug:             false,
116@@ -410,7 +410,7 @@ func unmarshal(filename string, cfg *Config) (err error) {
117 					}
118 				}
119 
120-				var color tcell.Color
121+				var color vaxis.Color
122 				if err = parseColor(colorStr, &color); err != nil {
123 					return err
124 				}
M go.mod
+10, -15
 1@@ -3,27 +3,22 @@ module git.sr.ht/~delthas/senpai
 2 go 1.18
 3 
 4 require (
 5-	git.sr.ht/~emersion/go-scfg v0.0.0-20231004133111-9dce55c8d63b
 6-	git.sr.ht/~rockorager/vaxis v0.8.5
 7+	git.sr.ht/~emersion/go-scfg v0.0.0-20240128091534-2ae16e782082
 8+	git.sr.ht/~rockorager/vaxis v0.9.2
 9 	github.com/delthas/go-libnp v0.0.0-20221222161248-0e45ece1f878
10-	github.com/delthas/go-localeinfo v0.0.0-20221116001557-686a1e185118
11-	github.com/gdamore/tcell/v2 v2.6.1-0.20230327043120-47ec3a77754f
12+	github.com/delthas/go-localeinfo v0.0.0-20240607105203-b2e834fc307d
13 	github.com/godbus/dbus/v5 v5.1.0
14-	github.com/mattn/go-runewidth v0.0.15
15-	golang.org/x/net v0.18.0
16-	golang.org/x/time v0.4.0
17+	github.com/rivo/uniseg v0.4.7
18+	golang.org/x/net v0.26.0
19+	golang.org/x/time v0.5.0
20 	mvdan.cc/xurls/v2 v2.5.0
21 )
22 
23 require (
24-	github.com/containerd/console v1.0.3 // indirect
25+	github.com/containerd/console v1.0.4 // indirect
26+	github.com/mattn/go-runewidth v0.0.15 // indirect
27 	github.com/mattn/go-sixel v0.0.5 // indirect
28-	github.com/rivo/uniseg v0.4.4 // indirect
29 	github.com/soniakeys/quant v1.0.0 // indirect
30-	golang.org/x/image v0.13.0 // indirect
31-	golang.org/x/sys v0.14.0 // indirect
32+	golang.org/x/image v0.18.0 // indirect
33+	golang.org/x/sys v0.21.0 // indirect
34 )
35-
36-replace github.com/gdamore/tcell/v2 => git.sr.ht/~delthas/vaxis-tcell v0.4.8-0.20240531132546-8a798f9059aa
37-
38-replace git.sr.ht/~rockorager/vaxis => git.sr.ht/~delthas/vaxis v0.8.6-0.20240617102656-146503a7e4f2
M go.sum
+19, -21
 1@@ -1,17 +1,15 @@
 2-git.sr.ht/~delthas/vaxis v0.8.6-0.20240617102656-146503a7e4f2 h1:/uP/UbVBm53m5NN0elWEM0XIx+JA6XhqDAex4qiZuTc=
 3-git.sr.ht/~delthas/vaxis v0.8.6-0.20240617102656-146503a7e4f2/go.mod h1:h94aKek3frIV1hJbdXjqnBqaLkbWXvV+UxAsQHg9bns=
 4-git.sr.ht/~delthas/vaxis-tcell v0.4.8-0.20240531132546-8a798f9059aa h1:BJ+FlhBKLzXI3DXqGPBIh8J6IOuu+VriDacOcYfV0p8=
 5-git.sr.ht/~delthas/vaxis-tcell v0.4.8-0.20240531132546-8a798f9059aa/go.mod h1:H3Xxz36WTDXi7zJPOhHkAHVbrwreMn/4awEGxOpEHfU=
 6-git.sr.ht/~emersion/go-scfg v0.0.0-20231004133111-9dce55c8d63b h1:Lf4oYBOJVmbYzrfqWfXUvSpXQPNMgnbN0efn5A7bH3M=
 7-git.sr.ht/~emersion/go-scfg v0.0.0-20231004133111-9dce55c8d63b/go.mod h1:ybgvEJTIx5XbaspSviB3KNa6OdPmAZqDoSud7z8fFlw=
 8-github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
 9-github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
10+git.sr.ht/~emersion/go-scfg v0.0.0-20240128091534-2ae16e782082 h1:9Udx5fm4vRtmgDIBjy2ef5QioHbzpw5oHabbhpAUyEw=
11+git.sr.ht/~emersion/go-scfg v0.0.0-20240128091534-2ae16e782082/go.mod h1:ybgvEJTIx5XbaspSviB3KNa6OdPmAZqDoSud7z8fFlw=
12+git.sr.ht/~rockorager/vaxis v0.9.2 h1:OKPaCG3S4b6jWnwUPC0zX+Zw16hhLcSgC2mLw/nwsV0=
13+git.sr.ht/~rockorager/vaxis v0.9.2/go.mod h1:h94aKek3frIV1hJbdXjqnBqaLkbWXvV+UxAsQHg9bns=
14+github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=
15+github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
16 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
17 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
18 github.com/delthas/go-libnp v0.0.0-20221222161248-0e45ece1f878 h1:v8W8eW7eb2bHFXBA80UKcoe0TvEu46NlTHSDRvgAbMU=
19 github.com/delthas/go-libnp v0.0.0-20221222161248-0e45ece1f878/go.mod h1:aGVXnhWpDlt5U4SphG97o1gszctZKvBTXy320E8Buw4=
20-github.com/delthas/go-localeinfo v0.0.0-20221116001557-686a1e185118 h1:Xzf9ra1QRJXD62gwudjI2iBq7x9CusvHd83Dg2OnUmE=
21-github.com/delthas/go-localeinfo v0.0.0-20221116001557-686a1e185118/go.mod h1:sG54BxlyQgIskYURLrg7mvhoGBe0Qq12DNtYRALwNa4=
22+github.com/delthas/go-localeinfo v0.0.0-20240607105203-b2e834fc307d h1:rH7dc+QFN4+Y8q5MxWUvt5PXsVpm1UKCEyuwf9BNO1s=
23+github.com/delthas/go-localeinfo v0.0.0-20240607105203-b2e834fc307d/go.mod h1:sG54BxlyQgIskYURLrg7mvhoGBe0Qq12DNtYRALwNa4=
24 github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
25 github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
26 github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
27@@ -20,20 +18,20 @@ github.com/mattn/go-sixel v0.0.5 h1:55w2FR5ncuhKhXrM5ly1eiqMQfZsnAHIpYNGZX03Cv8=
28 github.com/mattn/go-sixel v0.0.5/go.mod h1:h2Sss+DiUEHy0pUqcIB6PFXo5Cy8sTQEFr3a9/5ZLNw=
29 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
30 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
31-github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
32-github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
33+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
34+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
35 github.com/soniakeys/quant v1.0.0 h1:N1um9ktjbkZVcywBVAAYpZYSHxEfJGzshHCxx/DaI0Y=
36 github.com/soniakeys/quant v1.0.0/go.mod h1:HI1k023QuVbD4H8i9YdfZP2munIHU4QpjsImz6Y6zds=
37 github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
38-golang.org/x/image v0.13.0 h1:3cge/F/QTkNLauhf2QoE9zp+7sr+ZcL4HnoZmdwg9sg=
39-golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk=
40-golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
41-golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
42-golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
43-golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
44-golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
45-golang.org/x/time v0.4.0 h1:Z81tqI5ddIoXDPvVQ7/7CC9TnLM7ubaFG2qXYd5BbYY=
46-golang.org/x/time v0.4.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
47+golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
48+golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
49+golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
50+golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
51+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
52+golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
53+golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
54+golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
55+golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
56 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
57 mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
58 mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=
+126, -93
  1@@ -6,7 +6,7 @@ import (
  2 	"strings"
  3 	"time"
  4 
  5-	"github.com/gdamore/tcell/v2"
  6+	"git.sr.ht/~rockorager/vaxis"
  7 )
  8 
  9 const Overlay = "/overlay"
 10@@ -16,7 +16,8 @@ func IsSplitRune(r rune) bool {
 11 }
 12 
 13 type point struct {
 14-	X, I  int
 15+	X     int // in cells
 16+	I     int // in bytes
 17 	Split bool
 18 }
 19 
 20@@ -40,7 +41,7 @@ type Line struct {
 21 	At        time.Time
 22 	Head      string
 23 	Body      StyledString
 24-	HeadColor tcell.Color
 25+	HeadColor vaxis.Color
 26 	Notify    NotifyType
 27 	Highlight bool
 28 	Readable  bool
 29@@ -56,7 +57,7 @@ func (l *Line) IsZero() bool {
 30 	return l.Body.string == ""
 31 }
 32 
 33-func (l *Line) computeSplitPoints() {
 34+func (l *Line) computeSplitPoints(vx *Vaxis) {
 35 	if l.splitPoints == nil {
 36 		l.splitPoints = []point{}
 37 	}
 38@@ -77,7 +78,7 @@ func (l *Line) computeSplitPoints() {
 39 		}
 40 
 41 		lastWasSplit = curIsSplit
 42-		width += runeWidth(r)
 43+		width += runeWidth(vx, r)
 44 	}
 45 
 46 	if !lastWasSplit {
 47@@ -89,7 +90,8 @@ func (l *Line) computeSplitPoints() {
 48 	}
 49 }
 50 
 51-func (l *Line) NewLines(width int) []int {
 52+// NewLines returns the offsets, in bytes, where the line should be split.
 53+func (l *Line) NewLines(vx *Vaxis, width int) []int {
 54 	// Beware! This function was made by your local Test Driven Developper™ who
 55 	// doesn't understand one bit of this function and how it works (though it
 56 	// might not work that well if you're here...).  The code below is thus very
 57@@ -149,20 +151,22 @@ func (l *Line) NewLines(width int) []int {
 58 			// terminal.  In this case, no newline is placed before (like in the
 59 			// 2nd if-else branch).  The for loop is used to place newlines in
 60 			// the word.
 61-			// TODO handle multi-codepoint graphemes?? :(
 62-			wordWidth := 0
 63-			h := 1
 64-			for j, r := range l.Body.string[sp1.I:sp2.I] {
 65-				wordWidth += runeWidth(r)
 66-				if h*width < x+wordWidth {
 67+			s := l.Body.string[sp1.I:sp2.I]
 68+			j := 0
 69+			for s != "" {
 70+				c, wordWidth := firstCluster(vx, []rune(s))
 71+				if width < x+wordWidth {
 72+					x = 0
 73 					l.newLines = append(l.newLines, sp1.I+j)
 74-					h++
 75 				}
 76+				x += wordWidth
 77+				j += len(c)
 78+				s = s[len(c):]
 79 			}
 80-			x = (x + wordWidth) % width
 81-			if x == 0 {
 82+			if x == width {
 83 				// The placement of the word is such that it ends right at the
 84 				// end of the row.
 85+				x = 0
 86 				l.newLines = append(l.newLines, sp2.I)
 87 			}
 88 		} else {
 89@@ -212,12 +216,12 @@ type buffer struct {
 90 	lines []Line
 91 	topic string
 92 
 93-	scrollAmt int
 94+	scrollAmt int // offset in lines from the bottom
 95 	isAtTop   bool
 96 }
 97 
 98 type BufferList struct {
 99-	colors ConfigColors
100+	ui *UI
101 
102 	list    []buffer
103 	overlay *buffer
104@@ -229,18 +233,15 @@ type BufferList struct {
105 	textWidth    int
106 
107 	showBufferNumbers bool
108-
109-	doMergeLine func(former *Line, addition Line)
110 }
111 
112 // NewBufferList returns a new BufferList.
113 // Call Resize() once before using it.
114-func NewBufferList(colors ConfigColors, mergeLine func(*Line, Line)) BufferList {
115+func NewBufferList(ui *UI) BufferList {
116 	return BufferList{
117-		colors:      colors,
118-		list:        []buffer{},
119-		clicked:     -1,
120-		doMergeLine: mergeLine,
121+		ui:      ui,
122+		list:    []buffer{},
123+		clicked: -1,
124 	}
125 }
126 
127@@ -276,7 +277,7 @@ func (bs *BufferList) To(i int) bool {
128 		if len(bs.list) <= bs.current {
129 			bs.current = len(bs.list) - 1
130 		}
131-		bs.clearRead(i)
132+		bs.clearRead(bs.current)
133 		b := bs.list[bs.current]
134 		b.unreadRuler = b.read
135 		if len(b.lines) > 0 {
136@@ -423,12 +424,12 @@ func (bs *BufferList) RemoveNetwork(netID string) {
137 }
138 
139 func (bs *BufferList) mergeLine(former *Line, addition Line) (keepLine bool) {
140-	bs.doMergeLine(former, addition)
141+	bs.ui.config.MergeLine(former, addition)
142 	if former.Body.string == "" {
143 		return false
144 	}
145 	former.width = 0
146-	former.computeSplitPoints()
147+	former.computeSplitPoints(bs.ui.vx)
148 	return true
149 }
150 
151@@ -453,10 +454,10 @@ func (bs *BufferList) AddLine(netID, title string, line Line) {
152 		}
153 		// TODO change b.scrollAmt if it's not 0 and bs.current is idx.
154 	} else {
155-		line.computeSplitPoints()
156+		line.computeSplitPoints(bs.ui.vx)
157 		b.lines = append(b.lines, line)
158 		if b == current && 0 < b.scrollAmt {
159-			b.scrollAmt += len(line.NewLines(bs.textWidth)) + 1
160+			b.scrollAmt += len(line.NewLines(bs.ui.vx, bs.textWidth)) + 1
161 		}
162 	}
163 
164@@ -495,7 +496,7 @@ func (bs *BufferList) AddLines(netID, title string, before, after []Line) {
165 					if b.openedOnce {
166 						line.Body = line.Body.ParseURLs()
167 					}
168-					line.computeSplitPoints()
169+					line.computeSplitPoints(bs.ui.vx)
170 				}
171 				lines = append(lines, line)
172 			}
173@@ -577,7 +578,7 @@ func (bs *BufferList) UpdateRead() (netID, title string, timestamp time.Time) {
174 		if y >= b.scrollAmt && line.Readable {
175 			break
176 		}
177-		y += len(line.NewLines(bs.textWidth)) + 1
178+		y += len(line.NewLines(bs.ui.vx, bs.textWidth)) + 1
179 	}
180 	if line != nil && line.At.After(b.read) {
181 		b.read = line.At
182@@ -626,7 +627,7 @@ func (bs *BufferList) ScrollUpHighlight() bool {
183 			b.scrollAmt = y - bs.tlHeight + 1
184 			return true
185 		}
186-		y += len(line.NewLines(bs.textWidth)) + 1
187+		y += len(line.NewLines(bs.ui.vx, bs.textWidth)) + 1
188 	}
189 	return false
190 }
191@@ -640,7 +641,7 @@ func (bs *BufferList) ScrollDownHighlight() bool {
192 		if line.Highlight {
193 			yLastHighlight = y
194 		}
195-		y += len(line.NewLines(bs.textWidth)) + 1
196+		y += len(line.NewLines(bs.ui.vx, bs.textWidth)) + 1
197 	}
198 	b.scrollAmt = yLastHighlight
199 	return b.scrollAmt != 0
200@@ -671,7 +672,7 @@ func (bs *BufferList) cur() *buffer {
201 	return &bs.list[bs.current]
202 }
203 
204-func (bs *BufferList) DrawVerticalBufferList(screen tcell.Screen, x0, y0, width, height int, offset *int) {
205+func (bs *BufferList) DrawVerticalBufferList(vx *Vaxis, x0, y0, width, height int, offset *int) {
206 	if y0+len(bs.list)-*offset < height {
207 		*offset = y0 + len(bs.list) - height
208 		if *offset < 0 {
209@@ -680,25 +681,27 @@ func (bs *BufferList) DrawVerticalBufferList(screen tcell.Screen, x0, y0, width,
210 	}
211 
212 	width--
213-	drawVerticalLine(screen, x0+width, y0, height)
214-	clearArea(screen, x0, y0, width, height)
215+	drawVerticalLine(vx, x0+width, y0, height)
216+	clearArea(vx, x0, y0, width, height)
217 
218 	indexPadding := 1 + int(math.Ceil(math.Log10(float64(len(bs.list)))))
219 	for i, b := range bs.list[*offset:] {
220 		bi := *offset + i
221 		x := x0
222 		y := y0 + i
223-		st := tcell.StyleDefault
224+		var st vaxis.Style
225 		if b.unread {
226-			st = st.Bold(true).Foreground(bs.colors.Unread)
227+			st.Attribute |= vaxis.AttrBold
228+			st.Foreground = bs.ui.config.Colors.Unread
229 		}
230 		if bi == bs.current || bi == bs.clicked {
231-			st = st.Reverse(true)
232+			st.Attribute |= vaxis.AttrReverse
233 		}
234 		if bs.showBufferNumbers {
235-			indexSt := st.Foreground(tcell.ColorGray)
236+			indexSt := st
237+			indexSt.Foreground = ColorGray
238 			indexText := fmt.Sprintf("%d:", bi+1)
239-			printString(screen, &x, y, Styled(indexText, indexSt))
240+			printString(vx, &x, y, Styled(indexText, indexSt))
241 			x = x0 + indexPadding
242 		}
243 
244@@ -707,28 +710,36 @@ func (bs *BufferList) DrawVerticalBufferList(screen tcell.Screen, x0, y0, width,
245 			title = b.netName
246 		} else {
247 			if bi == bs.current || bi == bs.clicked {
248-				screen.SetContent(x, y, ' ', nil, tcell.StyleDefault.Reverse(true))
249-				screen.SetContent(x+1, y, ' ', nil, tcell.StyleDefault.Reverse(true))
250+				st := vaxis.Style{
251+					Attribute: vaxis.AttrReverse,
252+				}
253+				setCell(vx, x, y, ' ', st)
254+				setCell(vx, x+1, y, ' ', st)
255 			}
256 			x += 2
257 			title = b.title
258 		}
259-		title = truncate(title, width-(x-x0), "\u2026")
260-		printString(screen, &x, y, Styled(title, st))
261+		title = truncate(vx, title, width-(x-x0), "\u2026")
262+		printString(vx, &x, y, Styled(title, st))
263 
264 		if bi == bs.current || bi == bs.clicked {
265-			st := tcell.StyleDefault.Reverse(true)
266+			st := vaxis.Style{
267+				Attribute: vaxis.AttrReverse,
268+			}
269 			for ; x < x0+width; x++ {
270-				screen.SetContent(x, y, ' ', nil, st)
271+				setCell(vx, x, y, ' ', st)
272 			}
273-			screen.SetContent(x, y, 0x2590, nil, st)
274+			setCell(vx, x, y, ' ', st)
275+			setCell(vx, x, y, '▐', st)
276 		}
277 
278 		if b.highlights != 0 {
279-			highlightSt := st.Foreground(tcell.ColorRed).Reverse(true)
280+			highlightSt := st
281+			highlightSt.Foreground = ColorRed
282+			highlightSt.Attribute |= vaxis.AttrReverse
283 			highlightText := fmt.Sprintf(" %d ", b.highlights)
284 			x = x0 + width - len(highlightText)
285-			printString(screen, &x, y, Styled(highlightText, highlightSt))
286+			printString(vx, &x, y, Styled(highlightText, highlightSt))
287 		}
288 	}
289 }
290@@ -741,7 +752,7 @@ func (bs *BufferList) HorizontalBufferOffset(x int, offset int) int {
291 				return -1
292 			}
293 		}
294-		x -= bufferWidth(&b)
295+		x -= bs.bufferWidth(&b)
296 		if x < 0 {
297 			return offset + i
298 		}
299@@ -761,7 +772,7 @@ func (bs *BufferList) GetLeftMost(screenWidth int) int {
300 		if leftMost < bs.current {
301 			width++
302 		}
303-		width += bufferWidth(&bs.list[leftMost])
304+		width += bs.bufferWidth(&bs.list[leftMost])
305 		if width > screenWidth {
306 			return leftMost + 1 // Went offscreen, need to go one step back
307 		}
308@@ -770,12 +781,12 @@ func (bs *BufferList) GetLeftMost(screenWidth int) int {
309 	return 0
310 }
311 
312-func bufferWidth(b *buffer) int {
313+func (bs *BufferList) bufferWidth(b *buffer) int {
314 	width := 0
315 	if b.title == "" {
316-		width += stringWidth(b.netName)
317+		width += stringWidth(bs.ui.vx, b.netName)
318 	} else {
319-		width += stringWidth(b.title)
320+		width += stringWidth(bs.ui.vx, b.title)
321 	}
322 	if 0 < b.highlights {
323 		width += 2 + len(fmt.Sprintf("%d", b.highlights))
324@@ -783,12 +794,12 @@ func bufferWidth(b *buffer) int {
325 	return width
326 }
327 
328-func (bs *BufferList) DrawHorizontalBufferList(screen tcell.Screen, x0, y0, width int, offset *int) {
329+func (bs *BufferList) DrawHorizontalBufferList(vx *Vaxis, x0, y0, width int, offset *int) {
330 	x := width
331 	for i := len(bs.list) - 1; i >= 0; i-- {
332 		b := &bs.list[i]
333 		x--
334-		x -= bufferWidth(b)
335+		x -= bs.bufferWidth(b)
336 		if x <= 10 {
337 			break
338 		}
339@@ -803,45 +814,47 @@ func (bs *BufferList) DrawHorizontalBufferList(screen tcell.Screen, x0, y0, widt
340 		if width <= x-x0 {
341 			break
342 		}
343-		st := tcell.StyleDefault
344+		var st vaxis.Style
345 		if b.unread {
346-			st = st.Bold(true).Foreground(bs.colors.Unread)
347+			st.Attribute |= vaxis.AttrBold
348+			st.Foreground = bs.ui.config.Colors.Unread
349 		} else if i == bs.current {
350-			st = st.Underline(true)
351+			st.UnderlineStyle = vaxis.UnderlineSingle
352 		}
353 		if i == bs.clicked {
354-			st = st.Reverse(true)
355+			st.Attribute |= vaxis.AttrReverse
356 		}
357 
358 		var title string
359 		if b.title == "" {
360-			st = st.Dim(true)
361+			st.Attribute |= vaxis.AttrDim
362 			title = b.netName
363 		} else {
364 			title = b.title
365 		}
366-		title = truncate(title, width-x, "\u2026")
367-		printString(screen, &x, y0, Styled(title, st))
368+		title = truncate(vx, title, width-x, "\u2026")
369+		printString(vx, &x, y0, Styled(title, st))
370 
371 		if 0 < b.highlights {
372-			st = st.Foreground(tcell.ColorRed).Reverse(true)
373-			screen.SetContent(x, y0, ' ', nil, st)
374+			st.Foreground = ColorRed
375+			st.Attribute |= vaxis.AttrReverse
376+			setCell(vx, x, y0, ' ', st)
377 			x++
378-			printNumber(screen, &x, y0, st, b.highlights)
379-			screen.SetContent(x, y0, ' ', nil, st)
380+			printNumber(vx, &x, y0, st, b.highlights)
381+			setCell(vx, x, y0, ' ', st)
382 			x++
383 		}
384-		screen.SetContent(x, y0, ' ', nil, tcell.StyleDefault)
385+		setCell(vx, x, y0, ' ', vaxis.Style{})
386 		x++
387 	}
388 	for x < width {
389-		screen.SetContent(x, y0, ' ', nil, tcell.StyleDefault)
390+		setCell(vx, x, y0, ' ', vaxis.Style{})
391 		x++
392 	}
393 }
394 
395-func (bs *BufferList) DrawTimeline(screen tcell.Screen, x0, y0, nickColWidth int) {
396-	clearArea(screen, x0, y0, bs.tlInnerWidth+nickColWidth+9, bs.tlHeight+2)
397+func (bs *BufferList) DrawTimeline(vx *Vaxis, x0, y0, nickColWidth int) {
398+	clearArea(vx, x0, y0, bs.tlInnerWidth+nickColWidth+9, bs.tlHeight+2)
399 
400 	b := bs.cur()
401 	if !b.openedOnce {
402@@ -852,9 +865,9 @@ func (bs *BufferList) DrawTimeline(screen tcell.Screen, x0, y0, nickColWidth int
403 	}
404 
405 	xTopic := x0
406-	printString(screen, &xTopic, y0, Styled(b.topic, tcell.StyleDefault))
407+	printString(vx, &xTopic, y0, Styled(b.topic, vaxis.Style{}))
408 	y0++
409-	drawHorizontalLine(screen, x0, y0, bs.tlInnerWidth+nickColWidth+9)
410+	drawHorizontalLine(vx, x0, y0, bs.tlInnerWidth+nickColWidth+9)
411 	y0++
412 
413 	if bs.textWidth < bs.tlInnerWidth {
414@@ -871,15 +884,17 @@ func (bs *BufferList) DrawTimeline(screen tcell.Screen, x0, y0, nickColWidth int
415 		x1 := x0 + 9 + nickColWidth
416 
417 		line := &b.lines[i]
418-		nls := line.NewLines(bs.textWidth)
419+		nls := line.NewLines(bs.ui.vx, bs.textWidth)
420 
421 		if !rulerDrawn {
422 			isRead := !line.At.After(b.unreadRuler)
423 			if isRead && yi > y0 {
424 				yi--
425-				st := tcell.StyleDefault.Foreground(tcell.ColorGray)
426-				printIdent(screen, x0+7, yi, nickColWidth, Styled("--", st))
427-				drawHorizontalLine(screen, x0, yi, 9+nickColWidth+bs.tlInnerWidth)
428+				st := vaxis.Style{
429+					Foreground: ColorGray,
430+				}
431+				printIdent(vx, x0+7, yi, nickColWidth, Styled("--", st))
432+				drawHorizontalLine(vx, x0, yi, 9+nickColWidth+bs.tlInnerWidth)
433 				rulerDrawn = true
434 			}
435 		}
436@@ -898,36 +913,45 @@ func (bs *BufferList) DrawTimeline(screen tcell.Screen, x0, y0, nickColWidth int
437 			showDate = yb != ya || mb != ma || dd != da
438 		}
439 		if showDate {
440-			st := tcell.StyleDefault.Bold(true)
441+			st := vaxis.Style{
442+				Attribute: vaxis.AttrBold,
443+			}
444 			// as a special case, always draw the first visible message date, even if it is a continuation line
445 			yd := yi
446 			if yd < y0 {
447 				yd = y0
448 			}
449-			printDate(screen, x0, yd, st, line.At.Local())
450+			printDate(vx, x0, yd, st, line.At.Local())
451 		} else if b.lines[i-1].At.Truncate(time.Minute) != line.At.Truncate(time.Minute) && yi >= y0 {
452-			st := tcell.StyleDefault.Foreground(tcell.ColorGray)
453-			printTime(screen, x0, yi, st, line.At.Local())
454+			st := vaxis.Style{
455+				Foreground: ColorGray,
456+			}
457+			printTime(vx, x0, yi, st, line.At.Local())
458 		}
459 
460 		if yi >= y0 {
461-			identSt := tcell.StyleDefault.
462-				Foreground(line.HeadColor).
463-				Reverse(line.Highlight)
464-			printIdent(screen, x0+7, yi, nickColWidth, Styled(line.Head, identSt))
465+			identSt := vaxis.Style{
466+				Foreground: line.HeadColor,
467+			}
468+			if line.Highlight {
469+				identSt.Attribute |= vaxis.AttrReverse
470+			}
471+			printIdent(vx, x0+7, yi, nickColWidth, Styled(line.Head, identSt))
472 		}
473 
474 		x := x1
475 		y := yi
476-		style := tcell.StyleDefault
477+		var style vaxis.Style
478 		nextStyles := line.Body.styles
479 
480-		for i, r := range line.Body.string {
481-			if 0 < len(nextStyles) && nextStyles[0].Start == i {
482+		lbi := 0
483+		l := []rune(line.Body.string)
484+		for len(l) > 0 {
485+			if 0 < len(nextStyles) && nextStyles[0].Start == lbi {
486 				style = nextStyles[0].Style
487 				nextStyles = nextStyles[1:]
488 			}
489-			if 0 < len(nls) && i == nls[0] {
490+			if 0 < len(nls) && lbi == nls[0] {
491 				x = x1
492 				y++
493 				nls = nls[1:]
494@@ -936,14 +960,23 @@ func (bs *BufferList) DrawTimeline(screen tcell.Screen, x0, y0, nickColWidth int
495 				}
496 			}
497 
498-			if y != yi && x == x1 && IsSplitRune(r) {
499+			if y != yi && x == x1 && IsSplitRune(l[0]) {
500+				lbi += len(string(l[0]))
501+				l = l[1:]
502 				continue
503 			}
504 
505 			if y >= y0 {
506-				screen.SetContent(x, y, r, nil, style)
507+				dx, di := printCluster(vx, x, y, -1, l, style)
508+				x += dx
509+				lbi += len(string(l[:di]))
510+				l = l[di:]
511+			} else {
512+				c, cw := firstCluster(vx, l)
513+				x += cw
514+				lbi += len(c)
515+				l = l[len([]rune(c)):]
516 			}
517-			x += runeWidth(r)
518 		}
519 	}
520 
+3, -3
 1@@ -7,7 +7,7 @@ import (
 2 
 3 func assertSplitPoints(t *testing.T, body string, expected []point) {
 4 	l := Line{Body: PlainString(body)}
 5-	l.computeSplitPoints()
 6+	l.computeSplitPoints(nil)
 7 
 8 	if len(l.splitPoints) != len(expected) {
 9 		t.Errorf("%q: expected %d split points got %d", body, len(expected), len(l.splitPoints))
10@@ -72,9 +72,9 @@ func showSplit(s string, nls []int) string {
11 
12 func assertNewLines(t *testing.T, body string, width int, expected int) {
13 	l := Line{Body: PlainString(body)}
14-	l.computeSplitPoints()
15+	l.computeSplitPoints(nil)
16 
17-	actual := l.NewLines(width)
18+	actual := l.NewLines(nil, width)
19 
20 	if len(actual)+1 != expected {
21 		s := showSplit(body, actual)
+54, -49
  1@@ -3,15 +3,18 @@ package ui
  2 import (
  3 	"hash/fnv"
  4 
  5-	"github.com/gdamore/tcell/v2"
  6+	"git.sr.ht/~rockorager/vaxis"
  7 )
  8 
  9+var ColorRed = vaxis.IndexColor(9)
 10+var ColorGray = vaxis.IndexColor(8)
 11+
 12 type ColorSchemeType int
 13 
 14 type ColorScheme struct {
 15 	Type   ColorSchemeType
 16-	Others tcell.Color
 17-	Self   tcell.Color
 18+	Others vaxis.Color
 19+	Self   vaxis.Color
 20 }
 21 
 22 const (
 23@@ -20,59 +23,59 @@ const (
 24 	ColorSchemeFixed
 25 )
 26 
 27-var colors = map[ColorSchemeType][]tcell.Color{
 28+var colors = map[ColorSchemeType][]vaxis.Color{
 29 	// base 16 colors, excluding grayscale colors.
 30 	ColorSchemeBase: {
 31-		tcell.ColorMaroon,
 32-		tcell.ColorGreen,
 33-		tcell.ColorOlive,
 34-		tcell.ColorNavy,
 35-		tcell.ColorPurple,
 36-		tcell.ColorTeal,
 37-		tcell.ColorSilver,
 38-		tcell.ColorRed,
 39-		tcell.ColorLime,
 40-		tcell.ColorYellow,
 41-		tcell.ColorBlue,
 42-		tcell.ColorFuchsia,
 43-		tcell.ColorAqua,
 44+		vaxis.IndexColor(1),
 45+		vaxis.IndexColor(2),
 46+		vaxis.IndexColor(3),
 47+		vaxis.IndexColor(4),
 48+		vaxis.IndexColor(5),
 49+		vaxis.IndexColor(6),
 50+		vaxis.IndexColor(7),
 51+		vaxis.IndexColor(9),
 52+		vaxis.IndexColor(10),
 53+		vaxis.IndexColor(11),
 54+		vaxis.IndexColor(12),
 55+		vaxis.IndexColor(13),
 56+		vaxis.IndexColor(14),
 57 	},
 58 	// all XTerm extended colors with HSL saturation=1, light=0.5
 59 	ColorSchemeExtended: {
 60-		tcell.Color196, // HSL hue: 0°
 61-		tcell.Color202, // HSL hue: 22°
 62-		tcell.Color208, // HSL hue: 32°
 63-		tcell.Color214, // HSL hue: 41°
 64-		tcell.Color220, // HSL hue: 51°
 65-		tcell.Color226, // HSL hue: 60°
 66-		tcell.Color190, // HSL hue: 69°
 67-		tcell.Color154, // HSL hue: 79°
 68-		tcell.Color118, // HSL hue: 88°
 69-		tcell.Color82,  // HSL hue: 98°
 70-		tcell.Color46,  // HSL hue: 120°
 71-		tcell.Color47,  // HSL hue: 142°
 72-		tcell.Color48,  // HSL hue: 152°
 73-		tcell.Color49,  // HSL hue: 161°
 74-		tcell.Color50,  // HSL hue: 171°
 75-		tcell.Color51,  // HSL hue: 180°
 76-		tcell.Color45,  // HSL hue: 189°
 77-		tcell.Color39,  // HSL hue: 199°
 78-		tcell.Color33,  // HSL hue: 208°
 79-		tcell.Color27,  // HSL hue: 218°
 80-		tcell.Color21,  // HSL hue: 240°
 81-		tcell.Color57,  // HSL hue: 262°
 82-		tcell.Color93,  // HSL hue: 272°
 83-		tcell.Color129, // HSL hue: 281°
 84-		tcell.Color165, // HSL hue: 291°
 85-		tcell.Color201, // HSL hue: 300°
 86-		tcell.Color200, // HSL hue: 309°
 87-		tcell.Color199, // HSL hue: 319°
 88-		tcell.Color198, // HSL hue: 328°
 89-		tcell.Color197, // HSL hue: 338°
 90+		vaxis.IndexColor(196), // HSL hue: 0°
 91+		vaxis.IndexColor(202), // HSL hue: 22°
 92+		vaxis.IndexColor(208), // HSL hue: 32°
 93+		vaxis.IndexColor(214), // HSL hue: 41°
 94+		vaxis.IndexColor(220), // HSL hue: 51°
 95+		vaxis.IndexColor(226), // HSL hue: 60°
 96+		vaxis.IndexColor(190), // HSL hue: 69°
 97+		vaxis.IndexColor(154), // HSL hue: 79°
 98+		vaxis.IndexColor(118), // HSL hue: 88°
 99+		vaxis.IndexColor(82),  // HSL hue: 98°
100+		vaxis.IndexColor(46),  // HSL hue: 120°
101+		vaxis.IndexColor(47),  // HSL hue: 142°
102+		vaxis.IndexColor(48),  // HSL hue: 152°
103+		vaxis.IndexColor(49),  // HSL hue: 161°
104+		vaxis.IndexColor(50),  // HSL hue: 171°
105+		vaxis.IndexColor(51),  // HSL hue: 180°
106+		vaxis.IndexColor(45),  // HSL hue: 189°
107+		vaxis.IndexColor(39),  // HSL hue: 199°
108+		vaxis.IndexColor(33),  // HSL hue: 208°
109+		vaxis.IndexColor(27),  // HSL hue: 218°
110+		vaxis.IndexColor(21),  // HSL hue: 240°
111+		vaxis.IndexColor(57),  // HSL hue: 262°
112+		vaxis.IndexColor(93),  // HSL hue: 272°
113+		vaxis.IndexColor(129), // HSL hue: 281°
114+		vaxis.IndexColor(165), // HSL hue: 291°
115+		vaxis.IndexColor(201), // HSL hue: 300°
116+		vaxis.IndexColor(200), // HSL hue: 309°
117+		vaxis.IndexColor(199), // HSL hue: 319°
118+		vaxis.IndexColor(198), // HSL hue: 328°
119+		vaxis.IndexColor(197), // HSL hue: 338°
120 	},
121 }
122 
123-func IdentColor(scheme ColorScheme, ident string, self bool) tcell.Color {
124+func IdentColor(scheme ColorScheme, ident string, self bool) vaxis.Color {
125 	h := fnv.New32()
126 	_, _ = h.Write([]byte(ident))
127 	if scheme.Type == ColorSchemeFixed {
128@@ -88,6 +91,8 @@ func IdentColor(scheme ColorScheme, ident string, self bool) tcell.Color {
129 
130 func IdentString(scheme ColorScheme, ident string, self bool) StyledString {
131 	color := IdentColor(scheme, ident, self)
132-	style := tcell.StyleDefault.Foreground(color)
133+	style := vaxis.Style{
134+		Foreground: color,
135+	}
136 	return Styled(ident, style)
137 }
+175, -40
  1@@ -6,44 +6,181 @@ import (
  2 	"sync"
  3 	"time"
  4 
  5+	"git.sr.ht/~rockorager/vaxis"
  6 	"github.com/delthas/go-localeinfo"
  7-
  8-	"github.com/gdamore/tcell/v2"
  9+	"github.com/rivo/uniseg"
 10 )
 11 
 12-func printString(screen tcell.Screen, x *int, y int, s StyledString) {
 13-	style := tcell.StyleDefault
 14+var asciiStringCache []string
 15+
 16+func init() {
 17+	asciiStringCache = make([]string, 0x80)
 18+	for i := range asciiStringCache {
 19+		asciiStringCache[i] = string(rune(i))
 20+	}
 21+}
 22+
 23+var runeWidthMap = make(map[rune]int)
 24+
 25+func runeWidth(vx *Vaxis, r rune) int {
 26+	if vx == nil { // For tests only
 27+		return 1
 28+	}
 29+	if r == '\n' {
 30+		r = '↲'
 31+	}
 32+	if r <= 0x1F {
 33+		return 0
 34+	}
 35+	if r <= 0x7F {
 36+		return 1
 37+	}
 38+	if n, ok := runeWidthMap[r]; ok {
 39+		return n
 40+	}
 41+	n := vx.RenderedWidth(string([]rune{r}))
 42+	runeWidthMap[r] = n
 43+	return n
 44+}
 45+
 46+func stringWidth(vx *Vaxis, s string) int {
 47+	if vx == nil { // For tests only
 48+		return len(s)
 49+	}
 50+	if len(s) == 1 { // Single-character ASCII fast path
 51+		if s[0] <= 0x1F {
 52+			return 0
 53+		}
 54+		if s[0] <= 0x7F {
 55+			return 1
 56+		}
 57+	}
 58+	r := []rune(s)
 59+	if len(r) == 1 { // Single-character fast path
 60+		return runeWidth(vx, r[0])
 61+	}
 62+	return vx.RenderedWidth(s)
 63+}
 64+
 65+func truncate(vx *Vaxis, s string, w int, tail string) string {
 66+	if stringWidth(vx, s) <= w {
 67+		return s
 68+	}
 69+	w -= stringWidth(vx, tail)
 70+
 71+	width := 0
 72+	var sb strings.Builder
 73+	for _, c := range vaxis.Characters(s) {
 74+		chWidth := stringWidth(vx, c.Grapheme)
 75+		if width+chWidth > w {
 76+			break
 77+		}
 78+		width += chWidth
 79+		sb.WriteString(c.Grapheme)
 80+	}
 81+	sb.WriteString(tail)
 82+	return sb.String()
 83+}
 84+
 85+var clusterWidthMap = make(map[string]int)
 86+
 87+// width in cells
 88+func firstCluster(vx *Vaxis, r []rune) (c string, width int) {
 89+	if len(r) == 0 { // Empty fast-path
 90+		return "", 0
 91+	}
 92+	if r[0] == '\t' {
 93+		return " ", 1
 94+	}
 95+	if r[0] <= 0x7F { // ASCII fast-path
 96+		return asciiStringCache[int(r[0])], runeWidth(vx, r[0])
 97+	}
 98+	c, _, _, _ = uniseg.FirstGraphemeClusterInString(string(r), -1)
 99+
100+	var cw int
101+	if n, ok := clusterWidthMap[c]; ok {
102+		cw = n
103+	} else {
104+		cw = stringWidth(vx, c)
105+		clusterWidthMap[c] = cw
106+	}
107+	return c, cw
108+}
109+
110+func setCell(vx *Vaxis, x int, y int, r rune, st vaxis.Style) {
111+	vx.window.SetCell(x, y, vaxis.Cell{
112+		Character: vaxis.Character{
113+			Grapheme: string([]rune{r}),
114+		},
115+		Style: st,
116+	})
117+}
118+
119+// limit = -1 means no limit
120+// di is an offset in runes
121+func printCluster(vx *Vaxis, x int, y int, limit int, r []rune, st vaxis.Style) (dx int, di int) {
122+	if limit >= 0 && x >= limit {
123+		return 0, 0
124+	}
125+	var c string
126+	var w int
127+	if len(r) > 0 && r[0] <= 0x7F { // ASCII fast-path
128+		c = asciiStringCache[int(r[0])]
129+		w = runeWidth(vx, r[0])
130+		di = 1
131+	} else {
132+		c, w = firstCluster(vx, r)
133+		di = len([]rune(c))
134+	}
135+	if limit >= 0 && w > limit-x {
136+		return 0, 0
137+	}
138+	vx.window.SetCell(x, y, vaxis.Cell{
139+		Character: vaxis.Character{
140+			Grapheme: c,
141+		},
142+		Style: st,
143+	})
144+	return w, di
145+}
146+
147+func printString(vx *Vaxis, x *int, y int, s StyledString) {
148+	var st vaxis.Style
149 	nextStyles := s.styles
150-	for i, r := range s.string {
151+
152+	i := 0
153+	sr := []rune(s.string)
154+	for len(sr) > 0 {
155 		if 0 < len(nextStyles) && nextStyles[0].Start == i {
156-			style = nextStyles[0].Style
157+			st = nextStyles[0].Style
158 			nextStyles = nextStyles[1:]
159 		}
160-		screen.SetContent(*x, y, r, nil, style)
161-		*x += runeWidth(r)
162+		dx, di := printCluster(vx, *x, y, -1, sr, st)
163+		*x += dx
164+		sr = sr[di:]
165 	}
166 }
167 
168-func printIdent(screen tcell.Screen, x, y, width int, s StyledString) {
169-	s.string = truncate(s.string, width, "\u2026")
170-	x += width - stringWidth(s.string)
171-	st := tcell.StyleDefault
172+func printIdent(vx *Vaxis, x, y, width int, s StyledString) {
173+	s.string = truncate(vx, s.string, width, "\u2026")
174+	x += width - stringWidth(vx, s.string)
175+	var st vaxis.Style
176 	if len(s.styles) != 0 && s.styles[0].Start == 0 {
177 		st = s.styles[0].Style
178 	}
179-	screen.SetContent(x-1, y, ' ', nil, st)
180-	printString(screen, &x, y, s)
181+	setCell(vx, x-1, y, ' ', st)
182+	printString(vx, &x, y, s)
183 	if len(s.styles) != 0 {
184 		// TODO check if it's not a style that is from the truncated
185 		// part of s.
186 		st = s.styles[len(s.styles)-1].Style
187 	}
188-	screen.SetContent(x, y, ' ', nil, st)
189+	setCell(vx, x, y, ' ', st)
190 }
191 
192-func printNumber(screen tcell.Screen, x *int, y int, st tcell.Style, n int) {
193+func printNumber(vx *Vaxis, x *int, y int, st vaxis.Style, n int) {
194 	s := Styled(fmt.Sprintf("%d", n), st)
195-	printString(screen, x, y, s)
196+	printString(vx, x, y, s)
197 }
198 
199 var dateConfig sync.Once
200@@ -84,7 +221,7 @@ func loadDateInfo() {
201 	}
202 }
203 
204-func printDate(screen tcell.Screen, x int, y int, st tcell.Style, t time.Time) {
205+func printDate(vx *Vaxis, x int, y int, st vaxis.Style, t time.Time) {
206 	dateConfig.Do(loadDateInfo)
207 	_, m, d := t.Date()
208 	var left, right int
209@@ -97,42 +234,40 @@ func printDate(screen tcell.Screen, x int, y int, st tcell.Style, t time.Time) {
210 	l1 := rune(left%10) + '0'
211 	r0 := rune(right/10) + '0'
212 	r1 := rune(right%10) + '0'
213-	screen.SetContent(x+0, y, l0, nil, st)
214-	screen.SetContent(x+1, y, l1, nil, st)
215-	screen.SetContent(x+2, y, '/', nil, st)
216-	screen.SetContent(x+3, y, r0, nil, st)
217-	screen.SetContent(x+4, y, r1, nil, st)
218+
219+	setCell(vx, x+0, y, l0, st)
220+	setCell(vx, x+1, y, l1, st)
221+	setCell(vx, x+2, y, '/', st)
222+	setCell(vx, x+3, y, r0, st)
223+	setCell(vx, x+4, y, r1, st)
224 }
225 
226-func printTime(screen tcell.Screen, x int, y int, st tcell.Style, t time.Time) {
227+func printTime(vx *Vaxis, x int, y int, st vaxis.Style, t time.Time) {
228 	hr0 := rune(t.Hour()/10) + '0'
229 	hr1 := rune(t.Hour()%10) + '0'
230 	mn0 := rune(t.Minute()/10) + '0'
231 	mn1 := rune(t.Minute()%10) + '0'
232-	screen.SetContent(x+0, y, hr0, nil, st)
233-	screen.SetContent(x+1, y, hr1, nil, st)
234-	screen.SetContent(x+2, y, ':', nil, st)
235-	screen.SetContent(x+3, y, mn0, nil, st)
236-	screen.SetContent(x+4, y, mn1, nil, st)
237+	setCell(vx, x+0, y, hr0, st)
238+	setCell(vx, x+1, y, hr1, st)
239+	setCell(vx, x+2, y, ':', st)
240+	setCell(vx, x+3, y, mn0, st)
241+	setCell(vx, x+4, y, mn1, st)
242 }
243 
244-func clearArea(screen tcell.Screen, x0, y0, width, height int) {
245-	for x := x0; x < x0+width; x++ {
246-		for y := y0; y < y0+height; y++ {
247-			screen.SetContent(x, y, ' ', nil, tcell.StyleDefault)
248-		}
249-	}
250+func clearArea(vx *Vaxis, x0, y0, width, height int) {
251+	vx.window.New(x0, y0, width, height).Clear()
252 }
253 
254-func drawHorizontalLine(screen tcell.Screen, x0, y, width int) {
255-	st := tcell.StyleDefault.Foreground(tcell.ColorGray)
256+func drawHorizontalLine(vx *Vaxis, x0, y, width int) {
257 	for x := x0; x < x0+width; x++ {
258-		screen.SetContent(x, y, 0x2500, nil, st)
259+		setCell(vx, x, y, '─', vaxis.Style{
260+			Foreground: ColorGray,
261+		})
262 	}
263 }
264 
265-func drawVerticalLine(screen tcell.Screen, x, y0, height int) {
266+func drawVerticalLine(vx *Vaxis, x, y0, height int) {
267 	for y := y0; y < y0+height; y++ {
268-		screen.SetContent(x, y, 0x2502, nil, tcell.StyleDefault)
269+		setCell(vx, x, y, '│', vaxis.Style{})
270 	}
271 }
+175, -124
  1@@ -3,7 +3,7 @@ package ui
  2 import (
  3 	"strings"
  4 
  5-	"github.com/gdamore/tcell/v2"
  6+	"git.sr.ht/~rockorager/vaxis"
  7 )
  8 
  9 type Completion struct {
 10@@ -11,40 +11,56 @@ type Completion struct {
 11 	EndIdx    int
 12 	Text      []rune
 13 	Display   []rune
 14-	CursorIdx int
 15+	CursorIdx int // in runes
 16+}
 17+
 18+type editorLine struct {
 19+	runes    []rune
 20+	clusters []int
 21+}
 22+
 23+func newEditorLine() editorLine {
 24+	return editorLine{
 25+		runes:    []rune{},
 26+		clusters: []int{0},
 27+	}
 28+}
 29+
 30+func (l *editorLine) copy() editorLine {
 31+	return editorLine{
 32+		runes:    append([]rune{}, l.runes...),
 33+		clusters: append([]int{}, l.clusters...),
 34+	}
 35 }
 36 
 37 // Editor is the text field where the user writes messages and commands.
 38 type Editor struct {
 39-	colors ConfigColors
 40+	ui *UI
 41 
 42 	// text contains the written runes. An empty slice means no text is written.
 43-	text [][]rune
 44+	text []editorLine
 45 
 46 	// history contains the original content of each previously sent message.
 47 	// It gets out of sync with text when history is modified à la readline,
 48 	// then overwrites text when a new message is added.
 49-	history [][]rune
 50+	history []editorLine
 51 
 52 	lineIdx int
 53 
 54-	// textWidth[i] contains the width of string(text[:i]). Therefore
 55+	// textWidth[i] contains the width of text.runes[:text.clusters[i]]. Therefore
 56 	// len(textWidth) is always strictly greater than 0 and textWidth[0] is
 57 	// always 0.
 58 	textWidth []int
 59 
 60-	// cursorIdx is the index in text of the placement of the cursor, or is
 61-	// equal to len(text) if the cursor is at the end.
 62+	// cursorIdx is the index of clusters in text of the placement of the cursor.
 63 	cursorIdx int
 64 
 65-	// offsetIdx is the number of elements of text that are skipped when
 66-	// rendering.
 67+	// offsetIdx is the number of clusters in text that are skipped when rendering.
 68 	offsetIdx int
 69 
 70 	// width is the width of the screen.
 71 	width int
 72 
 73-	autoComplete func(cursorIdx int, text []rune) []Completion
 74 	autoCache    []Completion
 75 	autoCacheIdx int
 76 
 77@@ -58,13 +74,12 @@ type Editor struct {
 78 
 79 // NewEditor returns a new Editor.
 80 // Call Resize() once before using it.
 81-func NewEditor(colors ConfigColors, autoComplete func(cursorIdx int, text []rune) []Completion) Editor {
 82+func NewEditor(ui *UI) Editor {
 83 	return Editor{
 84-		colors:       colors,
 85-		text:         [][]rune{{}},
 86-		history:      [][]rune{},
 87-		textWidth:    []int{0},
 88-		autoComplete: autoComplete,
 89+		ui:        ui,
 90+		text:      []editorLine{newEditorLine()},
 91+		history:   []editorLine{},
 92+		textWidth: []int{0},
 93 	}
 94 }
 95 
 96@@ -80,18 +95,57 @@ func (e *Editor) Resize(width int) {
 97 
 98 // Content result must not be modified.
 99 func (e *Editor) Content() []rune {
100-	return e.text[e.lineIdx]
101-}
102-
103-func (e *Editor) TextLen() int {
104-	return len(e.text[e.lineIdx])
105+	return e.text[e.lineIdx].runes
106+}
107+
108+func (e *Editor) Empty() bool {
109+	return len(e.text[e.lineIdx].runes) == 0
110+}
111+
112+// recompute must be called when runes is changed, to update
113+// clusters, textWidth.
114+func (e *Editor) recompute() {
115+	c := make([]int, 0, len(e.text[e.lineIdx].runes)+1)
116+	w := make([]int, 0, len(e.text[e.lineIdx].runes)+1)
117+	nc := 0
118+	nw := 0
119+	for _, g := range vaxis.Characters(string(e.text[e.lineIdx].runes)) {
120+		c = append(c, nc)
121+		w = append(w, nw)
122+		nc += len([]rune(g.Grapheme))
123+		nw += stringWidth(e.ui.vx, g.Grapheme)
124+	}
125+	c = append(c, nc)
126+	w = append(w, nw)
127+	e.text[e.lineIdx].clusters = c
128+	e.textWidth = w
129+}
130+
131+// setCursor sets cursorIdx to the (grapheme cluster) offset
132+// corresponding to the passed rune offset runeIdx, rounding up
133+// to the next grapheme cluster as needed.
134+func (e *Editor) setCursor(runeIdx int) {
135+	for i, o := range e.text[e.lineIdx].clusters {
136+		if o >= runeIdx {
137+			e.cursorIdx = i
138+			break
139+		}
140+	}
141+	if e.width <= e.textWidth[e.cursorIdx]-e.textWidth[e.offsetIdx] {
142+		e.offsetIdx += 16
143+		// If we went too far right, go back enough to show one cluster.
144+		max := len(e.text[e.lineIdx].clusters) - 2
145+		if max < e.offsetIdx {
146+			e.offsetIdx = max
147+		}
148+	}
149 }
150 
151 func (e *Editor) PutRune(r rune) {
152 	e.autoCache = nil
153 	lowerRune := runeToLower(r)
154-	if e.backsearch && e.cursorIdx < e.TextLen() {
155-		lowerNext := runeToLower(e.text[e.lineIdx][e.cursorIdx])
156+	if e.backsearch && e.cursorIdx < len(e.text[e.lineIdx].clusters)-1 {
157+		lowerNext := runeToLower(e.text[e.lineIdx].runes[e.text[e.lineIdx].clusters[e.cursorIdx]])
158 		if lowerRune == lowerNext {
159 			e.right()
160 			e.backsearchPattern = append(e.backsearchPattern, lowerRune)
161@@ -99,7 +153,6 @@ func (e *Editor) PutRune(r rune) {
162 		}
163 	}
164 	e.putRune(r)
165-	e.right()
166 	if e.backsearch {
167 		wasEmpty := len(e.backsearchPattern) == 0
168 		e.backsearchPattern = append(e.backsearchPattern, lowerRune)
169@@ -111,30 +164,32 @@ func (e *Editor) PutRune(r rune) {
170 	}
171 }
172 
173+// putRune inserts a rune at the current cursor position,
174+// then updates moves the cursor position after that rune.
175+// (If inserting the rune merged a grapheme cluster, we
176+// move the cursor after that cluster.)
177 func (e *Editor) putRune(r rune) {
178-	e.text[e.lineIdx] = append(e.text[e.lineIdx], ' ')
179-	copy(e.text[e.lineIdx][e.cursorIdx+1:], e.text[e.lineIdx][e.cursorIdx:])
180-	e.text[e.lineIdx][e.cursorIdx] = r
181-	e.bumpOldestChange()
182+	ci := e.text[e.lineIdx].clusters[e.cursorIdx]
183+	e.text[e.lineIdx].runes = append(e.text[e.lineIdx].runes, ' ')
184+	copy(e.text[e.lineIdx].runes[ci+1:], e.text[e.lineIdx].runes[ci:])
185+	e.text[e.lineIdx].runes[ci] = r
186 
187-	rw := runeWidth(r)
188-	tw := e.textWidth[len(e.textWidth)-1]
189-	e.textWidth = append(e.textWidth, tw+rw)
190-	for i := e.cursorIdx + 1; i < len(e.textWidth); i++ {
191-		e.textWidth[i] = rw + e.textWidth[i-1]
192-	}
193+	e.recompute()
194+	e.setCursor(ci + 1)
195+
196+	e.bumpOldestChange()
197 }
198 
199-func (e *Editor) RemRune() (ok bool) {
200+func (e *Editor) RemCluster() (ok bool) {
201 	ok = 0 < e.cursorIdx
202 	if !ok {
203 		return
204 	}
205-	e.remRuneAt(e.cursorIdx - 1)
206+	e.remClusterAt(e.cursorIdx - 1)
207 	e.left()
208 	e.autoCache = nil
209 	if e.backsearch {
210-		if e.TextLen() == 0 || len(e.backsearchPattern) == 0 {
211+		if e.Empty() || len(e.backsearchPattern) == 0 {
212 			e.backsearchEnd()
213 		} else {
214 			e.backsearchPattern = e.backsearchPattern[:len(e.backsearchPattern)-1]
215@@ -144,29 +199,24 @@ func (e *Editor) RemRune() (ok bool) {
216 	return
217 }
218 
219-func (e *Editor) RemRuneForward() (ok bool) {
220-	ok = e.cursorIdx < len(e.text[e.lineIdx])
221+func (e *Editor) RemClusterForward() (ok bool) {
222+	ok = e.cursorIdx < len(e.text[e.lineIdx].clusters)-1
223 	if !ok {
224 		return
225 	}
226-	e.remRuneAt(e.cursorIdx)
227+	e.remClusterAt(e.cursorIdx)
228 	e.autoCache = nil
229 	e.backsearchEnd()
230 	return
231 }
232 
233-func (e *Editor) remRuneAt(idx int) {
234-	// TODO avoid looping twice
235-	rw := e.textWidth[idx+1] - e.textWidth[idx]
236-	for i := idx + 1; i < len(e.textWidth); i++ {
237-		e.textWidth[i] -= rw
238-	}
239-	copy(e.textWidth[idx+1:], e.textWidth[idx+2:])
240-	e.textWidth = e.textWidth[:len(e.textWidth)-1]
241-
242-	copy(e.text[e.lineIdx][idx:], e.text[e.lineIdx][idx+1:])
243-	e.text[e.lineIdx] = e.text[e.lineIdx][:len(e.text[e.lineIdx])-1]
244+func (e *Editor) remClusterAt(idx int) {
245+	rs := e.text[e.lineIdx].clusters[idx]
246+	re := e.text[e.lineIdx].clusters[idx+1]
247+	copy(e.text[e.lineIdx].runes[rs:], e.text[e.lineIdx].runes[re:])
248+	e.text[e.lineIdx].runes = e.text[e.lineIdx].runes[:len(e.text[e.lineIdx].runes)-(re-rs)]
249 
250+	e.recompute()
251 	e.bumpOldestChange()
252 }
253 
254@@ -182,16 +232,16 @@ func (e *Editor) RemWord() (ok bool) {
255 	// Hello world|
256 	// Hello |
257 	// |
258-	for e.cursorIdx > 0 && line[e.cursorIdx-1] == ' ' {
259-		e.remRuneAt(e.cursorIdx - 1)
260+	for e.cursorIdx > 0 && line.runes[line.clusters[e.cursorIdx-1]] == ' ' {
261+		e.remClusterAt(e.cursorIdx - 1)
262 		e.left()
263 	}
264 
265 	for i := e.cursorIdx - 1; i >= 0; i -= 1 {
266-		if line[i] == ' ' {
267+		if line.runes[line.clusters[i]] == ' ' {
268 			break
269 		}
270-		e.remRuneAt(i)
271+		e.remClusterAt(i)
272 		e.left()
273 	}
274 
275@@ -201,19 +251,19 @@ func (e *Editor) RemWord() (ok bool) {
276 }
277 
278 func (e *Editor) Flush() string {
279-	content := string(e.text[e.lineIdx])
280+	l := e.text[e.lineIdx]
281+	content := string(l.runes)
282 	if len(content) > 0 {
283-		// intended []rune -> string -> []rune conversion to make copies
284-		e.history = append(e.history, []rune(content))
285+		e.history = append(e.history, l.copy())
286 	}
287 	for i, line := range e.history[e.oldestTextChange:] {
288 		i := i + e.oldestTextChange
289-		e.text[i] = append(e.text[i][:0], line...)
290+		e.text[i] = line.copy()
291 	}
292 	if len(content) > 0 {
293-		e.text = append(e.text, []rune{})
294+		e.text = append(e.text, newEditorLine())
295 	} else {
296-		e.text[len(e.text)-1] = []rune{}
297+		e.text[len(e.text)-1] = newEditorLine()
298 	}
299 	e.lineIdx = len(e.text) - 1
300 	e.textWidth = e.textWidth[:1]
301@@ -226,10 +276,10 @@ func (e *Editor) Flush() string {
302 }
303 
304 func (e *Editor) Clear() bool {
305-	if e.TextLen() == 0 {
306+	if e.Empty() {
307 		return false
308 	}
309-	e.text[e.lineIdx] = []rune{}
310+	e.text[e.lineIdx] = newEditorLine()
311 	e.bumpOldestChange()
312 	e.textWidth = e.textWidth[:1]
313 	e.cursorIdx = 0
314@@ -240,10 +290,10 @@ func (e *Editor) Clear() bool {
315 
316 func (e *Editor) Set(text string) {
317 	r := []rune(text)
318-	e.text[e.lineIdx] = r
319+	e.text[e.lineIdx].runes = r
320+	e.recompute()
321 	e.bumpOldestChange()
322-	e.cursorIdx = len(r)
323-	e.computeTextWidth()
324+	e.cursorIdx = len(e.text[e.lineIdx].clusters) - 1
325 	e.offsetIdx = 0
326 	for e.width < e.textWidth[e.cursorIdx]-e.textWidth[e.offsetIdx]+16 {
327 		e.offsetIdx++
328@@ -259,13 +309,14 @@ func (e *Editor) Right() {
329 }
330 
331 func (e *Editor) right() {
332-	if e.cursorIdx == len(e.text[e.lineIdx]) {
333+	if e.cursorIdx == len(e.text[e.lineIdx].clusters)-1 {
334 		return
335 	}
336 	e.cursorIdx++
337 	if e.width <= e.textWidth[e.cursorIdx]-e.textWidth[e.offsetIdx] {
338 		e.offsetIdx += 16
339-		max := len(e.text[e.lineIdx]) - 1
340+		// If we went too far right, go back enough to show one cluster.
341+		max := len(e.text[e.lineIdx].clusters) - 2
342 		if max < e.offsetIdx {
343 			e.offsetIdx = max
344 		}
345@@ -275,14 +326,14 @@ func (e *Editor) right() {
346 func (e *Editor) RightWord() {
347 	line := e.text[e.lineIdx]
348 
349-	if e.cursorIdx == len(line) {
350+	if e.cursorIdx == len(line.clusters)-1 {
351 		return
352 	}
353 
354-	for e.cursorIdx < len(line) && line[e.cursorIdx] == ' ' {
355+	for e.cursorIdx < len(line.clusters)-1 && line.runes[line.clusters[e.cursorIdx]] == ' ' {
356 		e.Right()
357 	}
358-	for i := e.cursorIdx; i < len(line) && line[i] != ' '; i += 1 {
359+	for i := e.cursorIdx; i < len(line.clusters)-1 && line.runes[line.clusters[i]] != ' '; i++ {
360 		e.Right()
361 	}
362 }
363@@ -312,10 +363,10 @@ func (e *Editor) LeftWord() {
364 
365 	line := e.text[e.lineIdx]
366 
367-	for e.cursorIdx > 0 && line[e.cursorIdx-1] == ' ' {
368+	for e.cursorIdx > 0 && line.runes[line.clusters[e.cursorIdx-1]] == ' ' {
369 		e.left()
370 	}
371-	for i := e.cursorIdx - 1; i >= 0 && line[i] != ' '; i -= 1 {
372+	for i := e.cursorIdx - 1; i >= 0 && line.runes[line.clusters[i]] != ' '; i-- {
373 		e.left()
374 	}
375 
376@@ -334,10 +385,10 @@ func (e *Editor) Home() {
377 }
378 
379 func (e *Editor) End() {
380-	if e.cursorIdx == len(e.text[e.lineIdx]) {
381+	if e.cursorIdx == len(e.text[e.lineIdx].clusters)-1 {
382 		return
383 	}
384-	e.cursorIdx = len(e.text[e.lineIdx])
385+	e.cursorIdx = len(e.text[e.lineIdx].clusters) - 1
386 	for e.width < e.textWidth[e.cursorIdx]-e.textWidth[e.offsetIdx]+16 {
387 		e.offsetIdx++
388 	}
389@@ -354,7 +405,7 @@ func (e *Editor) Up() {
390 		return
391 	}
392 	e.lineIdx--
393-	e.computeTextWidth()
394+	e.recompute()
395 	e.cursorIdx = 0
396 	e.offsetIdx = 0
397 	e.autoCache = nil
398@@ -372,7 +423,7 @@ func (e *Editor) Down() {
399 		return
400 	}
401 	e.lineIdx++
402-	e.computeTextWidth()
403+	e.recompute()
404 	e.cursorIdx = 0
405 	e.offsetIdx = 0
406 	e.autoCache = nil
407@@ -382,7 +433,7 @@ func (e *Editor) Down() {
408 
409 func (e *Editor) AutoComplete() (ok bool) {
410 	if e.autoCache == nil {
411-		e.autoCache = e.autoComplete(e.cursorIdx, e.text[e.lineIdx])
412+		e.autoCache = e.ui.config.AutoComplete(e.text[e.lineIdx].clusters[e.cursorIdx], e.text[e.lineIdx].runes)
413 		if len(e.autoCache) == 0 {
414 			e.autoCache = nil
415 			return false
416@@ -391,10 +442,10 @@ func (e *Editor) AutoComplete() (ok bool) {
417 		return
418 	}
419 
420-	e.text[e.lineIdx] = e.autoCache[e.autoCacheIdx].Text
421+	e.text[e.lineIdx].runes = e.autoCache[e.autoCacheIdx].Text
422+	e.recompute()
423 	e.bumpOldestChange()
424-	e.cursorIdx = e.autoCache[e.autoCacheIdx].CursorIdx
425-	e.computeTextWidth()
426+	e.setCursor(e.autoCache[e.autoCacheIdx].CursorIdx)
427 	if len(e.textWidth) <= e.offsetIdx {
428 		e.offsetIdx = 0
429 	}
430@@ -410,7 +461,7 @@ func (e *Editor) AutoComplete() (ok bool) {
431 func (e *Editor) BackSearch() {
432 	if !e.backsearch {
433 		e.backsearch = true
434-		e.backsearchPattern = []rune(strings.ToLower(string(e.text[e.lineIdx])))
435+		e.backsearchPattern = []rune(strings.ToLower(string(e.text[e.lineIdx].runes)))
436 	}
437 	e.backsearchUpdate(e.lineIdx - 1)
438 }
439@@ -421,10 +472,10 @@ func (e *Editor) backsearchUpdate(start int) {
440 	}
441 	pattern := string(e.backsearchPattern)
442 	for i := start; i >= 0; i-- {
443-		if match := strings.Index(strings.ToLower(string(e.text[i])), pattern); match >= 0 {
444+		if match := strings.Index(strings.ToLower(string(e.text[i].runes)), pattern); match >= 0 {
445 			e.lineIdx = i
446-			e.computeTextWidth()
447-			e.cursorIdx = runeOffset(string(e.text[i]), match) + len(e.backsearchPattern)
448+			e.recompute()
449+			e.setCursor(runeOffset(string(e.text[i].runes), match) + len(e.backsearchPattern))
450 			e.offsetIdx = 0
451 			for e.width < e.textWidth[e.cursorIdx]-e.textWidth[e.offsetIdx]+16 {
452 				e.offsetIdx++
453@@ -439,15 +490,6 @@ func (e *Editor) backsearchEnd() {
454 	e.backsearch = false
455 }
456 
457-func (e *Editor) computeTextWidth() {
458-	e.textWidth = e.textWidth[:1]
459-	rw := 0
460-	for _, r := range e.text[e.lineIdx] {
461-		rw += runeWidth(r)
462-		e.textWidth = append(e.textWidth, rw)
463-	}
464-}
465-
466 // call this everytime e.text is modified
467 func (e *Editor) bumpOldestChange() {
468 	if e.oldestTextChange > e.lineIdx {
469@@ -455,18 +497,18 @@ func (e *Editor) bumpOldestChange() {
470 	}
471 }
472 
473-func (e *Editor) Draw(screen tcell.Screen, x0, y int, hint string) {
474-	st := tcell.StyleDefault
475+func (e *Editor) Draw(vx *Vaxis, x0, y int, hint string) {
476+	var st vaxis.Style
477 
478 	x := x0
479-	i := e.offsetIdx
480-	text := e.text[e.lineIdx]
481+	i := e.text[e.lineIdx].clusters[e.offsetIdx]
482+	text := e.text[e.lineIdx].runes
483 	showCursor := true
484 
485 	if len(text) == 0 && len(hint) > 0 && !e.backsearch {
486 		i = 0
487 		text = []rune(hint)
488-		st = st.Foreground(e.colors.Status)
489+		st.Foreground = e.ui.config.Colors.Status
490 		showCursor = false
491 	}
492 
493@@ -478,32 +520,37 @@ func (e *Editor) Draw(screen tcell.Screen, x0, y int, hint string) {
494 		autoEnd = e.autoCache[e.autoCacheIdx].EndIdx
495 	}
496 
497-	for i < len(text) && x < x0+e.width {
498-		r := text[i]
499+	ci := e.text[e.lineIdx].clusters[e.cursorIdx]
500+	for i < len(text) {
501+		r := text[i:]
502 		s := st
503-		if e.backsearch && i < e.cursorIdx && i >= e.cursorIdx-len(e.backsearchPattern) {
504-			s = s.Underline(true)
505+		if e.backsearch && i < ci && i >= ci-len(e.backsearchPattern) {
506+			s.UnderlineStyle = vaxis.UnderlineSingle
507 		}
508 		if i >= autoStart && i < autoEnd {
509-			s = s.Underline(true)
510+			s.UnderlineStyle = vaxis.UnderlineSingle
511 		}
512 		if i == autoStart {
513 			autoX = x
514 		}
515-		if r == '\n' {
516-			s = s.Bold(true).Foreground(tcell.ColorRed)
517-			r = '↲'
518+		if r[0] == '\n' {
519+			s.Attribute |= vaxis.AttrBold
520+			s.Foreground = ColorRed
521+			r = []rune{'↲'}
522+		}
523+		dx, di := printCluster(vx, x, y, x0+e.width, r, s)
524+		if di == 0 {
525+			break
526 		}
527-		screen.SetContent(x, y, r, nil, s)
528-		x += runeWidth(r)
529-		i++
530+		x += dx
531+		i += di
532 	}
533 	if i == autoStart {
534 		autoX = x
535 	}
536 
537 	for x < x0+e.width {
538-		screen.SetContent(x, y, ' ', nil, st)
539+		setCell(vx, x, y, ' ', st)
540 		x++
541 	}
542 
543@@ -533,27 +580,31 @@ func (e *Editor) Draw(screen tcell.Screen, x0, y int, hint string) {
544 
545 		x := autoX
546 		y := y - i - 1
547-		for _, r := range display {
548-			if x >= x0+e.width {
549-				break
550+		i := 0
551+		for i < len(display) {
552+			s := vaxis.Style{
553+				Background: vaxis.IndexColor(0),
554+				Attribute:  vaxis.AttrReverse,
555 			}
556-			s := st.Background(tcell.ColorBlack)
557-			s = s.Reverse(true)
558 			if i+autoOff == e.autoCacheIdx {
559-				s = s.Bold(true)
560+				s.Attribute |= vaxis.AttrBold
561 			} else {
562-				s = s.Dim(true)
563+				s.Attribute |= vaxis.AttrDim
564+			}
565+			dx, di := printCluster(vx, x, y, x0+e.width, display[i:], s)
566+			if di == 0 {
567+				break
568 			}
569-			screen.SetContent(x, y, r, nil, s)
570-			x += runeWidth(r)
571+			x += dx
572+			i += di
573 		}
574 	}
575 
576 	if showCursor {
577 		cursorX := x0 + e.textWidth[e.cursorIdx] - e.textWidth[e.offsetIdx]
578-		screen.ShowCursor(cursorX, y)
579+		vx.ShowCursor(cursorX, y, vaxis.CursorBeam)
580 	} else {
581-		screen.HideCursor()
582+		vx.HideCursor()
583 	}
584 }
585 
586@@ -563,7 +614,7 @@ func runeToLower(r rune) rune {
587 	return []rune(strings.ToLower(string(r)))[0]
588 }
589 
590-// runeOffset returns the rune index of the rune starting at byte i in string s
591+// runeOffset returns the rune index of the rune starting at byte pos in string s
592 func runeOffset(s string, pos int) int {
593 	n := 0
594 	for i := range s {
+26, -12
 1@@ -3,7 +3,10 @@ package ui
 2 import "testing"
 3 
 4 var hell = Editor{
 5-	text:      [][]rune{{'h', 'e', 'l', 'l'}},
 6+	text: []editorLine{{
 7+		runes:    []rune{'h', 'e', 'l', 'l'},
 8+		clusters: []int{0, 1, 2, 3, 4},
 9+	}},
10 	textWidth: []int{0, 1, 2, 3, 4},
11 	cursorIdx: 4,
12 	offsetIdx: 0,
13@@ -14,9 +17,17 @@ func assertEditorEq(t *testing.T, actual, expected Editor) {
14 	if len(actual.text) != len(expected.text) {
15 		t.Errorf("expected text len to be %d, got %d\n", len(expected.text), len(actual.text))
16 	} else {
17-		for i := 0; i < len(actual.text); i++ {
18-			a := actual.text[0][i]
19-			e := expected.text[0][i]
20+		for i := 0; i < len(actual.text[0].clusters); i++ {
21+			a := actual.text[0].clusters[i]
22+			e := expected.text[0].clusters[i]
23+
24+			if a != e {
25+				t.Errorf("expected cluster #%d to be '%c', got '%c'\n", i, e, a)
26+			}
27+		}
28+		for i := 0; i < len(actual.text[0].runes); i++ {
29+			a := actual.text[0].runes[i]
30+			e := expected.text[0].runes[i]
31 
32 			if a != e {
33 				t.Errorf("expected rune #%d to be '%c', got '%c'\n", i, e, a)
34@@ -51,11 +62,14 @@ func assertEditorEq(t *testing.T, actual, expected Editor) {
35 }
36 
37 func TestOneLetter(t *testing.T) {
38-	e := NewEditor(ConfigColors{}, nil)
39+	e := NewEditor(&UI{})
40 	e.Resize(5)
41 	e.PutRune('h')
42 	assertEditorEq(t, e, Editor{
43-		text:      [][]rune{{'h'}},
44+		text: []editorLine{{
45+			runes:    []rune{'h'},
46+			clusters: []int{0, 1},
47+		}},
48 		textWidth: []int{0, 1},
49 		cursorIdx: 1,
50 		offsetIdx: 0,
51@@ -64,7 +78,7 @@ func TestOneLetter(t *testing.T) {
52 }
53 
54 func TestFourLetters(t *testing.T) {
55-	e := NewEditor(ConfigColors{}, nil)
56+	e := NewEditor(&UI{})
57 	e.Resize(5)
58 	e.PutRune('h')
59 	e.PutRune('e')
60@@ -74,7 +88,7 @@ func TestFourLetters(t *testing.T) {
61 }
62 
63 func TestOneLeft(t *testing.T) {
64-	e := NewEditor(ConfigColors{}, nil)
65+	e := NewEditor(&UI{})
66 	e.Resize(5)
67 	e.PutRune('h')
68 	e.PutRune('l')
69@@ -86,11 +100,11 @@ func TestOneLeft(t *testing.T) {
70 }
71 
72 func TestOneRem(t *testing.T) {
73-	e := NewEditor(ConfigColors{}, nil)
74+	e := NewEditor(&UI{})
75 	e.Resize(5)
76 	e.PutRune('h')
77 	e.PutRune('l')
78-	e.RemRune()
79+	e.RemCluster()
80 	e.PutRune('e')
81 	e.PutRune('l')
82 	e.PutRune('l')
83@@ -98,13 +112,13 @@ func TestOneRem(t *testing.T) {
84 }
85 
86 func TestLeftAndRem(t *testing.T) {
87-	e := NewEditor(ConfigColors{}, nil)
88+	e := NewEditor(&UI{})
89 	e.Resize(5)
90 	e.PutRune('h')
91 	e.PutRune('l')
92 	e.PutRune('e')
93 	e.Left()
94-	e.RemRune()
95+	e.RemCluster()
96 	e.Right()
97 	e.PutRune('l')
98 	e.PutRune('l')
+1, -1
1@@ -44,7 +44,7 @@ func (ui *UI) notify(target NotifyEvent, title, content string) int {
2 		}
3 	}
4 
5-	ui.screen.Notify(title, content)
6+	ui.vx.Notify(title, content)
7 	return -1
8 }
9 
+69, -99
  1@@ -8,50 +8,32 @@ import (
  2 	"strings"
  3 	"unicode/utf8"
  4 
  5+	"git.sr.ht/~rockorager/vaxis"
  6 	"mvdan.cc/xurls/v2"
  7-
  8-	"github.com/gdamore/tcell/v2"
  9-	"github.com/mattn/go-runewidth"
 10 )
 11 
 12-var condition = runewidth.Condition{}
 13-
 14-func runeWidth(r rune) int {
 15-	if r == '\n' {
 16-		r = '↲'
 17-	}
 18-	return condition.RuneWidth(r)
 19-}
 20-
 21-func stringWidth(s string) int {
 22-	return condition.StringWidth(s)
 23-}
 24-
 25-func truncate(s string, w int, tail string) string {
 26-	return condition.Truncate(s, w, tail)
 27-}
 28-
 29 // Taken from <https://modern.ircdocs.horse/formatting.html>
 30 
 31-var baseCodes = []tcell.Color{
 32-	tcell.ColorWhite, tcell.ColorBlack, tcell.ColorBlue, tcell.ColorGreen,
 33-	tcell.ColorRed, tcell.ColorBrown, tcell.ColorPurple, tcell.ColorOrange,
 34-	tcell.ColorYellow, tcell.ColorLightGreen, tcell.ColorTeal, tcell.ColorLightCyan,
 35-	tcell.ColorLightBlue, tcell.ColorPink, tcell.ColorGray, tcell.ColorLightGray,
 36-}
 37-
 38-// unused
 39-var ansiCodes = []uint64{
 40-	/* 16-27 */ 52, 94, 100, 58, 22, 29, 23, 24, 17, 54, 53, 89,
 41-	/* 28-39 */ 88, 130, 142, 64, 28, 35, 30, 25, 18, 91, 90, 125,
 42-	/* 40-51 */ 124, 166, 184, 106, 34, 49, 37, 33, 19, 129, 127, 161,
 43-	/* 52-63 */ 196, 208, 226, 154, 46, 86, 51, 75, 21, 171, 201, 198,
 44-	/* 64-75 */ 203, 215, 227, 191, 83, 122, 87, 111, 63, 177, 207, 205,
 45-	/* 76-87 */ 217, 223, 229, 193, 157, 158, 159, 153, 147, 183, 219, 212,
 46-	/* 88-98 */ 16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231,
 47+var baseCodes = []vaxis.Color{
 48+	vaxis.IndexColor(15),
 49+	vaxis.IndexColor(0),
 50+	vaxis.IndexColor(4),
 51+	vaxis.IndexColor(2),
 52+	vaxis.IndexColor(9),
 53+	vaxis.IndexColor(1),
 54+	vaxis.IndexColor(5),
 55+	vaxis.IndexColor(3),
 56+	vaxis.IndexColor(11),
 57+	vaxis.IndexColor(10),
 58+	vaxis.IndexColor(6),
 59+	vaxis.IndexColor(14),
 60+	vaxis.IndexColor(12),
 61+	vaxis.IndexColor(13),
 62+	vaxis.IndexColor(8),
 63+	vaxis.IndexColor(7),
 64 }
 65 
 66-var hexCodes = []int32{
 67+var hexCodes = []uint32{
 68 	0x470000, 0x472100, 0x474700, 0x324700, 0x004700, 0x00472c, 0x004747, 0x002747, 0x000047, 0x2e0047, 0x470047, 0x47002a,
 69 	0x740000, 0x743a00, 0x747400, 0x517400, 0x007400, 0x007449, 0x007474, 0x004074, 0x000074, 0x4b0074, 0x740074, 0x740045,
 70 	0xb50000, 0xb56300, 0xb5b500, 0x7db500, 0x00b500, 0x00b571, 0x00b5b5, 0x0063b5, 0x0000b5, 0x7500b5, 0xb500b5, 0xb5006b,
 71@@ -61,20 +43,20 @@ var hexCodes = []int32{
 72 	0x000000, 0x131313, 0x282828, 0x363636, 0x4d4d4d, 0x656565, 0x818181, 0x9f9f9f, 0xbcbcbc, 0xe2e2e2, 0xffffff,
 73 }
 74 
 75-func colorFromCode(code int) (color tcell.Color) {
 76+func colorFromCode(code int) (color vaxis.Color) {
 77 	if code < 0 || 99 <= code {
 78-		color = tcell.ColorDefault
 79+		color = vaxis.Color(0)
 80 	} else if code < 16 {
 81 		color = baseCodes[code]
 82 	} else {
 83-		color = tcell.NewHexColor(hexCodes[code-16])
 84+		color = vaxis.HexColor(hexCodes[code-16])
 85 	}
 86 	return
 87 }
 88 
 89 type rangedStyle struct {
 90 	Start int // byte index at which Style is effective
 91-	Style tcell.Style
 92+	Style vaxis.Style
 93 }
 94 
 95 type StyledString struct {
 96@@ -90,7 +72,7 @@ func PlainSprintf(format string, a ...interface{}) StyledString {
 97 	return PlainString(fmt.Sprintf(format, a...))
 98 }
 99 
100-func Styled(s string, style tcell.Style) StyledString {
101+func Styled(s string, style vaxis.Style) StyledString {
102 	rStyle := rangedStyle{
103 		Start: 0,
104 		Style: style,
105@@ -105,25 +87,7 @@ func (s StyledString) String() string {
106 	return s.string
107 }
108 
109-func (s StyledString) Truncate(w int, tail StyledString) StyledString {
110-	if stringWidth(s.string) < w {
111-		return s
112-	}
113-	var sb StyledStringBuilder
114-	truncated := truncate(s.string, w-stringWidth(tail.string), "")
115-	sb.WriteString(truncated)
116-	for _, style := range s.styles {
117-		if len(truncated) <= style.Start {
118-			break
119-		}
120-		sb.styles = append(sb.styles, style)
121-	}
122-	sb.WriteStyledString(tail)
123-	return sb.StyledString()
124-}
125-
126-// https://github.com/mvdan/xurls/pull/75
127-var urlRegex, _ = xurls.StrictMatchingScheme(`([a-zA-Z][a-zA-Z.\-+]*://|(bitcoin|cid|file|geo|magnet|mailto|mid|sms|tel|xmpp):)`)
128+var urlRegex, _ = xurls.StrictMatchingScheme(xurls.AnyScheme)
129 
130 func (s StyledString) ParseURLs() StyledString {
131 	if !strings.ContainsRune(s.string, '.') {
132@@ -137,7 +101,6 @@ func (s StyledString) ParseURLs() StyledString {
133 	j := 0
134 	lastStyle := rangedStyle{
135 		Start: -1,
136-		Style: tcell.StyleDefault,
137 	}
138 	for i := 0; i < len(urls); i++ {
139 		u := urls[i]
140@@ -155,16 +118,20 @@ func (s StyledString) ParseURLs() StyledString {
141 			}
142 			if st.Start == ub {
143 				// a style already starts at this position, edit it
144-				lastStyle.Style = lastStyle.Style.Url(link).UrlId(id)
145+				lastStyle.Style.Hyperlink = link
146+				lastStyle.Style.HyperlinkParams = fmt.Sprintf("id=%v", id)
147 			}
148 			lastStyle = st
149 			styles = append(styles, st)
150 		}
151 		if lastStyle.Start != ub {
152 			// no style existed at this position, add one from the last style
153+			st := lastStyle.Style
154+			st.Hyperlink = link
155+			st.HyperlinkParams = fmt.Sprintf("id=%v", id)
156 			styles = append(styles, rangedStyle{
157 				Start: ub,
158-				Style: lastStyle.Style.Url(link).UrlId(id),
159+				Style: st,
160 			})
161 		}
162 		// find last style starting before or at url end
163@@ -174,16 +141,20 @@ func (s StyledString) ParseURLs() StyledString {
164 				break
165 			}
166 			if st.Start < ue {
167-				st.Style = st.Style.Url(link).UrlId(id)
168+				st.Style.Hyperlink = link
169+				st.Style.HyperlinkParams = fmt.Sprintf("id=%v", id)
170 			}
171 			lastStyle = st
172 			styles = append(styles, st)
173 		}
174 		if lastStyle.Start != ue {
175 			// no style existed at this position, add one from the last style without the hyperlink
176+			st := lastStyle.Style
177+			st.Hyperlink = ""
178+			st.HyperlinkParams = ""
179 			styles = append(styles, rangedStyle{
180 				Start: ue,
181-				Style: lastStyle.Style.Url("").UrlId(""),
182+				Style: st,
183 			})
184 		}
185 	}
186@@ -199,7 +170,7 @@ func isDigit(c byte) bool {
187 	return '0' <= c && c <= '9'
188 }
189 
190-func parseColorNumber(raw string) (color tcell.Color, n int) {
191+func parseColorNumber(raw string) (color vaxis.Color, n int) {
192 	if len(raw) == 0 || !isDigit(raw[0]) {
193 		return
194 	}
195@@ -217,27 +188,27 @@ func parseColorNumber(raw string) (color tcell.Color, n int) {
196 	return colorFromCode(code), 2
197 }
198 
199-func parseColor(raw string) (fg, bg tcell.Color, n int) {
200+func parseColor(raw string) (fg, bg vaxis.Color, n int) {
201 	fg, n = parseColorNumber(raw)
202 	raw = raw[n:]
203 
204 	if len(raw) == 0 || raw[0] != ',' {
205-		return fg, tcell.ColorDefault, n
206+		return fg, vaxis.Color(0), n
207 	}
208 
209 	n++
210 	bg, p := parseColorNumber(raw[1:])
211 	n += p
212 
213-	if bg == tcell.ColorDefault {
214+	if bg == vaxis.Color(0) {
215 		// Lone comma, do not parse as part of a color code.
216-		return fg, tcell.ColorDefault, n - 1
217+		return fg, vaxis.Color(0), n - 1
218 	}
219 
220 	return fg, bg, n
221 }
222 
223-func parseHexColorNumber(raw string) (color tcell.Color, n int) {
224+func parseHexColorNumber(raw string) (color vaxis.Color, n int) {
225 	if len(raw) < 6 {
226 		return
227 	}
228@@ -248,24 +219,24 @@ func parseHexColorNumber(raw string) (color tcell.Color, n int) {
229 	if err != nil {
230 		return
231 	}
232-	return tcell.NewHexColor(int32(value)), 6
233+	return vaxis.HexColor(uint32(value)), 6
234 }
235 
236-func parseHexColor(raw string) (fg, bg tcell.Color, n int) {
237+func parseHexColor(raw string) (fg, bg vaxis.Color, n int) {
238 	fg, n = parseHexColorNumber(raw)
239 	raw = raw[n:]
240 
241 	if len(raw) == 0 || raw[0] != ',' {
242-		return fg, tcell.ColorDefault, n
243+		return fg, vaxis.Color(0), n
244 	}
245 
246 	n++
247 	bg, p := parseHexColorNumber(raw[1:])
248 	n += p
249 
250-	if bg == tcell.ColorDefault {
251+	if bg == vaxis.Color(0) {
252 		// Lone comma, do not parse as part of a color code.
253-		return fg, tcell.ColorDefault, n - 1
254+		return fg, vaxis.Color(0), n - 1
255 	}
256 
257 	return fg, bg, n
258@@ -274,20 +245,18 @@ func parseHexColor(raw string) (fg, bg tcell.Color, n int) {
259 func IRCString(raw string) StyledString {
260 	var formatted strings.Builder
261 	var styles []rangedStyle
262-	var last tcell.Style
263+	var last vaxis.Style
264 
265 	for len(raw) != 0 {
266 		r, runeSize := utf8.DecodeRuneInString(raw)
267-		_, _, lastAttrs := last.Decompose()
268 		current := last
269 		if r == 0x0F {
270-			current = tcell.StyleDefault
271+			current = vaxis.Style{}
272 		} else if r == 0x02 {
273-			lastWasBold := lastAttrs&tcell.AttrBold != 0
274-			current = last.Bold(!lastWasBold)
275+			current.Attribute ^= vaxis.AttrBold
276 		} else if r == 0x03 || r == 0x04 {
277-			var fg tcell.Color
278-			var bg tcell.Color
279+			var fg vaxis.Color
280+			var bg vaxis.Color
281 			var n int
282 			if r == 0x03 {
283 				fg, bg, n = parseColor(raw[1:])
284@@ -298,25 +267,26 @@ func IRCString(raw string) StyledString {
285 			if n == 0 {
286 				// Both `fg` and `bg` are equal to
287 				// tcell.ColorDefault.
288-				current = last.Foreground(tcell.ColorDefault).
289-					Background(tcell.ColorDefault)
290-			} else if bg == tcell.ColorDefault {
291-				current = last.Foreground(fg)
292+				current.Foreground = vaxis.Color(0)
293+				current.Background = vaxis.Color(0)
294+			} else if bg == vaxis.Color(0) {
295+				current.Foreground = fg
296 			} else {
297-				current = last.Foreground(fg).Background(bg)
298+				current.Foreground = fg
299+				current.Background = bg
300 			}
301 		} else if r == 0x16 {
302-			lastWasReverse := lastAttrs&tcell.AttrReverse != 0
303-			current = last.Reverse(!lastWasReverse)
304+			current.Attribute ^= vaxis.AttrReverse
305 		} else if r == 0x1D {
306-			lastWasItalic := lastAttrs&tcell.AttrItalic != 0
307-			current = last.Italic(!lastWasItalic)
308+			current.Attribute ^= vaxis.AttrItalic
309 		} else if r == 0x1E {
310-			lastWasStrikeThrough := lastAttrs&tcell.AttrStrikeThrough != 0
311-			current = last.StrikeThrough(!lastWasStrikeThrough)
312+			current.Attribute ^= vaxis.AttrStrikethrough
313 		} else if r == 0x1F {
314-			lastWasUnderline := lastAttrs&tcell.AttrUnderline != 0
315-			current = last.Underline(!lastWasUnderline)
316+			if last.UnderlineStyle == vaxis.UnderlineOff {
317+				current.UnderlineStyle = vaxis.UnderlineSingle
318+			} else {
319+				current.UnderlineStyle = vaxis.UnderlineOff
320+			}
321 		} else {
322 			formatted.WriteRune(r)
323 		}
324@@ -362,7 +332,7 @@ func (sb *StyledStringBuilder) WriteStyledString(s StyledString) {
325 	sb.WriteString(s.string)
326 }
327 
328-func (sb *StyledStringBuilder) AddStyle(start int, style tcell.Style) {
329+func (sb *StyledStringBuilder) AddStyle(start int, style vaxis.Style) {
330 	for i := 0; i < len(sb.styles); i++ {
331 		if sb.styles[i].Start == i {
332 			sb.styles[i].Style = style
333@@ -382,7 +352,7 @@ func (sb *StyledStringBuilder) AddStyle(start int, style tcell.Style) {
334 	})
335 }
336 
337-func (sb *StyledStringBuilder) SetStyle(style tcell.Style) {
338+func (sb *StyledStringBuilder) SetStyle(style vaxis.Style) {
339 	sb.styles = append(sb.styles, rangedStyle{
340 		Start: sb.Len(),
341 		Style: style,
+11, -11
 1@@ -3,7 +3,7 @@ package ui
 2 import (
 3 	"testing"
 4 
 5-	"github.com/gdamore/tcell/v2"
 6+	"git.sr.ht/~rockorager/vaxis"
 7 )
 8 
 9 func assertIRCString(t *testing.T, input string, expected StyledString) {
10@@ -35,62 +35,62 @@ func TestIRCString(t *testing.T) {
11 	assertIRCString(t, "\x02hello", StyledString{
12 		string: "hello",
13 		styles: []rangedStyle{
14-			{Start: 0, Style: tcell.StyleDefault.Bold(true)},
15+			{Start: 0, Style: vaxis.Style{Attribute: vaxis.AttrBold}},
16 		},
17 	})
18 	assertIRCString(t, "\x035hello", StyledString{
19 		string: "hello",
20 		styles: []rangedStyle{
21-			{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown)},
22+			{Start: 0, Style: vaxis.Style{Foreground: vaxis.IndexColor(1)}},
23 		},
24 	})
25 	assertIRCString(t, "\x0305hello", StyledString{
26 		string: "hello",
27 		styles: []rangedStyle{
28-			{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown)},
29+			{Start: 0, Style: vaxis.Style{Foreground: vaxis.IndexColor(1)}},
30 		},
31 	})
32 	assertIRCString(t, "\x0305,0hello", StyledString{
33 		string: "hello",
34 		styles: []rangedStyle{
35-			{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown).Background(tcell.ColorWhite)},
36+			{Start: 0, Style: vaxis.Style{Foreground: vaxis.IndexColor(1), Background: vaxis.IndexColor(15)}},
37 		},
38 	})
39 	assertIRCString(t, "\x035,00hello", StyledString{
40 		string: "hello",
41 		styles: []rangedStyle{
42-			{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown).Background(tcell.ColorWhite)},
43+			{Start: 0, Style: vaxis.Style{Foreground: vaxis.IndexColor(1), Background: vaxis.IndexColor(15)}},
44 		},
45 	})
46 	assertIRCString(t, "\x0305,00hello", StyledString{
47 		string: "hello",
48 		styles: []rangedStyle{
49-			{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown).Background(tcell.ColorWhite)},
50+			{Start: 0, Style: vaxis.Style{Foreground: vaxis.IndexColor(1), Background: vaxis.IndexColor(15)}},
51 		},
52 	})
53 
54 	assertIRCString(t, "\x035,hello", StyledString{
55 		string: ",hello",
56 		styles: []rangedStyle{
57-			{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown)},
58+			{Start: 0, Style: vaxis.Style{Foreground: vaxis.IndexColor(1)}},
59 		},
60 	})
61 	assertIRCString(t, "\x0305,hello", StyledString{
62 		string: ",hello",
63 		styles: []rangedStyle{
64-			{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown)},
65+			{Start: 0, Style: vaxis.Style{Foreground: vaxis.IndexColor(1)}},
66 		},
67 	})
68 	assertIRCString(t, "\x03050hello", StyledString{
69 		string: "0hello",
70 		styles: []rangedStyle{
71-			{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown)},
72+			{Start: 0, Style: vaxis.Style{Foreground: vaxis.IndexColor(1)}},
73 		},
74 	})
75 	assertIRCString(t, "\x0305,000hello", StyledString{
76 		string: "0hello",
77 		styles: []rangedStyle{
78-			{Start: 0, Style: tcell.StyleDefault.Foreground(tcell.ColorBrown).Background(tcell.ColorWhite)},
79+			{Start: 0, Style: vaxis.Style{Foreground: vaxis.IndexColor(1), Background: vaxis.IndexColor(15)}},
80 		},
81 	})
82 }
+110, -81
  1@@ -7,7 +7,6 @@ import (
  2 	"time"
  3 
  4 	"git.sr.ht/~rockorager/vaxis"
  5-	"github.com/gdamore/tcell/v2"
  6 
  7 	"git.sr.ht/~delthas/senpai/irc"
  8 )
  9@@ -27,15 +26,20 @@ type Config struct {
 10 }
 11 
 12 type ConfigColors struct {
 13-	Status tcell.Color
 14-	Prompt tcell.Color
 15-	Unread tcell.Color
 16+	Status vaxis.Color
 17+	Prompt vaxis.Color
 18+	Unread vaxis.Color
 19 	Nicks  ColorScheme
 20 }
 21 
 22+type Vaxis struct {
 23+	*vaxis.Vaxis
 24+	window vaxis.Window
 25+}
 26+
 27 type UI struct {
 28-	screen tcell.Screen
 29-	Events chan tcell.Event
 30+	vx     *Vaxis
 31+	Events chan vaxis.Event
 32 	exit   atomic.Value // bool
 33 	config Config
 34 
 35@@ -68,34 +72,33 @@ func New(config Config) (ui *UI, err error) {
 36 		ui.memberWidth = config.MemberColWidth
 37 	}
 38 
 39-	ui.screen, err = tcell.NewScreen()
 40-	if err != nil {
 41-		return
 42-	}
 43-
 44-	err = ui.screen.Init()
 45+	var vx *vaxis.Vaxis
 46+	vx, err = vaxis.New(vaxis.Options{
 47+		DisableMouse: !config.Mouse,
 48+		CSIuBitMask:  vaxis.CSIuDisambiguate | vaxis.CSIuReportEvents | vaxis.CSIuAlternateKeys | vaxis.CSIuAllKeys | vaxis.CSIuAssociatedText,
 49+	})
 50 	if err != nil {
 51 		return
 52 	}
 53-	if ui.screen.HasMouse() && config.Mouse {
 54-		ui.screen.EnableMouse()
 55+	ui.vx = &Vaxis{
 56+		Vaxis:  vx,
 57+		window: vx.Window(),
 58 	}
 59-	ui.screen.EnablePaste()
 60-	ui.screen.SetCursorStyle(tcell.CursorStyleSteadyBar)
 61-	ui.screen.SetTitle("senpai")
 62-	ui.screen.SetAppID("senpai")
 63 
 64-	_, h := ui.screen.Size()
 65-	ui.screen.Clear()
 66-	ui.screen.ShowCursor(0, h-2)
 67+	ui.vx.SetTitle("senpai")
 68+	ui.vx.SetAppID("senpai")
 69+
 70+	_, h := ui.vx.window.Size()
 71+	ui.vx.window.Clear()
 72+	ui.vx.ShowCursor(0, h-2, vaxis.CursorBeam)
 73 
 74 	ui.exit.Store(false)
 75 
 76-	ui.Events = make(chan tcell.Event, 128)
 77+	ui.Events = make(chan vaxis.Event, 128)
 78 	go func() {
 79 		for !ui.ShouldExit() {
 80-			ev := ui.screen.PollEvent()
 81-			if ev == nil {
 82+			ev := ui.vx.PollEvent()
 83+			if _, ok := ev.(vaxis.QuitEvent); ok {
 84 				ui.Exit()
 85 				break
 86 			}
 87@@ -104,8 +107,8 @@ func New(config Config) (ui *UI, err error) {
 88 		close(ui.Events)
 89 	}()
 90 
 91-	ui.bs = NewBufferList(config.Colors, ui.config.MergeLine)
 92-	ui.e = NewEditor(config.Colors, ui.config.AutoComplete)
 93+	ui.bs = NewBufferList(ui)
 94+	ui.e = NewEditor(ui)
 95 	ui.Resize()
 96 
 97 	return
 98@@ -120,11 +123,8 @@ func (ui *UI) Exit() {
 99 }
100 
101 func (ui *UI) Close() {
102-	// See: https://github.com/gdamore/tcell/issues/623
103-	ui.screen.SetCursorStyle(tcell.CursorStyleDefault)
104-	ui.screen.Sync()
105-
106-	ui.screen.Fini()
107+	ui.vx.Refresh() // TODO is this needed?
108+	ui.vx.Close()
109 }
110 
111 func (ui *UI) Buffer(i int) (netID, title string, ok bool) {
112@@ -425,11 +425,11 @@ func (ui *UI) SetTitle(title string) {
113 		return
114 	}
115 	ui.title = title
116-	ui.screen.SetTitle(title)
117+	ui.vx.SetTitle(title)
118 }
119 
120 func (ui *UI) SetMouseShape(shape vaxis.MouseShape) {
121-	ui.screen.Vaxis().SetMouseShape(shape)
122+	ui.vx.SetMouseShape(shape)
123 }
124 
125 // InputContent result must not be modified.
126@@ -474,11 +474,11 @@ func (ui *UI) InputDown() {
127 }
128 
129 func (ui *UI) InputBackspace() (ok bool) {
130-	return ui.e.RemRune()
131+	return ui.e.RemCluster()
132 }
133 
134 func (ui *UI) InputDelete() (ok bool) {
135-	return ui.e.RemRuneForward()
136+	return ui.e.RemClusterForward()
137 }
138 
139 func (ui *UI) InputDeleteWord() (ok bool) {
140@@ -506,7 +506,8 @@ func (ui *UI) InputBackSearch() {
141 }
142 
143 func (ui *UI) Resize() {
144-	w, h := ui.screen.Size()
145+	ui.vx.window = ui.vx.Window() // Refresh window size
146+	w, h := ui.vx.window.Size()
147 	innerWidth := w - 9 - ui.channelWidth - ui.config.NickColWidth - ui.memberWidth
148 	if innerWidth <= 0 {
149 		innerWidth = 1 // will break display somewhat, but this is an edge case
150@@ -522,32 +523,32 @@ func (ui *UI) Resize() {
151 		ui.bs.ResizeTimeline(innerWidth, h-2, textWidth)
152 	}
153 	ui.ScrollToBuffer()
154-	ui.screen.Sync()
155+	ui.vx.Refresh()
156 }
157 
158 func (ui *UI) Size() (int, int) {
159-	return ui.screen.Size()
160+	return ui.vx.window.Size()
161 }
162 
163 func (ui *UI) Beep() {
164-	ui.screen.Beep()
165+	ui.vx.Bell()
166 }
167 
168 func (ui *UI) Notify(title string, body string) {
169-	ui.screen.Notify(title, body)
170+	ui.vx.Notify(title, body)
171 }
172 
173 func (ui *UI) Draw(members []irc.Member) {
174-	w, h := ui.screen.Size()
175+	w, h := ui.vx.window.Size()
176 
177-	ui.bs.DrawTimeline(ui.screen, ui.channelWidth, 0, ui.config.NickColWidth)
178+	ui.bs.DrawTimeline(ui.vx, ui.channelWidth, 0, ui.config.NickColWidth)
179 	if ui.channelWidth == 0 {
180-		ui.bs.DrawHorizontalBufferList(ui.screen, 0, h-1, w-ui.memberWidth, &ui.channelOffset)
181+		ui.bs.DrawHorizontalBufferList(ui.vx, 0, h-1, w-ui.memberWidth, &ui.channelOffset)
182 	} else {
183-		ui.bs.DrawVerticalBufferList(ui.screen, 0, 0, ui.channelWidth, h, &ui.channelOffset)
184+		ui.bs.DrawVerticalBufferList(ui.vx, 0, 0, ui.channelWidth, h, &ui.channelOffset)
185 	}
186 	if ui.memberWidth != 0 {
187-		ui.drawVerticalMemberList(ui.screen, w-ui.memberWidth, 0, ui.memberWidth, h, members, &ui.memberOffset)
188+		ui.drawVerticalMemberList(ui.vx, w-ui.memberWidth, 0, ui.memberWidth, h, members, &ui.memberOffset)
189 	}
190 	if ui.channelWidth == 0 {
191 		ui.drawStatusBar(ui.channelWidth, h-3, w-ui.memberWidth)
192@@ -556,19 +557,21 @@ func (ui *UI) Draw(members []irc.Member) {
193 	}
194 
195 	prompt := ui.prompt
196-	if ui.bs.HasOverlay() && ui.e.TextLen() == 0 {
197-		prompt = Styled(">", tcell.StyleDefault.Foreground(ui.config.Colors.Prompt))
198+	if ui.bs.HasOverlay() && ui.e.Empty() {
199+		prompt = Styled(">", vaxis.Style{
200+			Foreground: ui.config.Colors.Prompt,
201+		})
202 	}
203 	if ui.channelWidth == 0 {
204 		for x := 0; x < 9+ui.config.NickColWidth; x++ {
205-			ui.screen.SetContent(x, h-2, ' ', nil, tcell.StyleDefault)
206+			setCell(ui.vx, x, h-2, ' ', vaxis.Style{})
207 		}
208-		printIdent(ui.screen, 7, h-2, ui.config.NickColWidth, prompt)
209+		printIdent(ui.vx, 7, h-2, ui.config.NickColWidth, prompt)
210 	} else {
211 		for x := ui.channelWidth; x < 9+ui.channelWidth+ui.config.NickColWidth; x++ {
212-			ui.screen.SetContent(x, h-1, ' ', nil, tcell.StyleDefault)
213+			setCell(ui.vx, x, h-1, ' ', vaxis.Style{})
214 		}
215-		printIdent(ui.screen, ui.channelWidth+7, h-1, ui.config.NickColWidth, prompt)
216+		printIdent(ui.vx, ui.channelWidth+7, h-1, ui.config.NickColWidth, prompt)
217 	}
218 
219 	var hint string
220@@ -576,12 +579,12 @@ func (ui *UI) Draw(members []irc.Member) {
221 		hint = ui.overlayHint
222 	}
223 	if ui.channelWidth == 0 {
224-		ui.e.Draw(ui.screen, 9+ui.config.NickColWidth, h-2, hint)
225+		ui.e.Draw(ui.vx, 9+ui.config.NickColWidth, h-2, hint)
226 	} else {
227-		ui.e.Draw(ui.screen, 9+ui.channelWidth+ui.config.NickColWidth, h-1, hint)
228+		ui.e.Draw(ui.vx, 9+ui.channelWidth+ui.config.NickColWidth, h-1, hint)
229 	}
230 
231-	ui.screen.Show()
232+	ui.vx.Render()
233 }
234 
235 func (ui *UI) ScrollToBuffer() {
236@@ -590,7 +593,7 @@ func (ui *UI) ScrollToBuffer() {
237 		return
238 	}
239 
240-	w, h := ui.screen.Size()
241+	w, h := ui.vx.window.Size()
242 	var first int
243 	if ui.channelWidth > 0 {
244 		first = ui.bs.current - h + 1
245@@ -603,28 +606,32 @@ func (ui *UI) ScrollToBuffer() {
246 }
247 
248 func (ui *UI) drawStatusBar(x0, y, width int) {
249-	clearArea(ui.screen, x0, y, width, 1)
250+	clearArea(ui.vx, x0, y, width, 1)
251 
252 	if ui.status == "" {
253 		return
254 	}
255 
256 	var s StyledStringBuilder
257-	s.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
258+	s.SetStyle(vaxis.Style{
259+		Foreground: ColorGray,
260+	})
261 	s.WriteString("--")
262 
263 	x := x0 + 5 + ui.config.NickColWidth
264-	printString(ui.screen, &x, y, s.StyledString())
265+	printString(ui.vx, &x, y, s.StyledString())
266 	x += 2
267 
268 	s.Reset()
269-	s.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
270+	s.SetStyle(vaxis.Style{
271+		Foreground: ColorGray,
272+	})
273 	s.WriteString(ui.status)
274 
275-	printString(ui.screen, &x, y, s.StyledString())
276+	printString(ui.vx, &x, y, s.StyledString())
277 }
278 
279-func (ui *UI) drawVerticalMemberList(screen tcell.Screen, x0, y0, width, height int, members []irc.Member, offset *int) {
280+func (ui *UI) drawVerticalMemberList(vx *Vaxis, x0, y0, width, height int, members []irc.Member, offset *int) {
281 	if y0+len(members)-*offset < height {
282 		*offset = y0 + len(members) - height
283 		if *offset < 0 {
284@@ -632,15 +639,17 @@ func (ui *UI) drawVerticalMemberList(screen tcell.Screen, x0, y0, width, height
285 		}
286 	}
287 
288-	drawVerticalLine(screen, x0, y0, height)
289+	drawVerticalLine(vx, x0, y0, height)
290 	x0++
291 	width--
292-	clearArea(screen, x0, y0, width, height)
293+	clearArea(vx, x0, y0, width, height)
294 
295 	if _, channel := ui.bs.Current(); channel == "" {
296 		x := x0 + 1
297-		printString(screen, &x, y0, Styled("Help", tcell.StyleDefault.Foreground(ui.config.Colors.Status)))
298-		drawHorizontalLine(screen, x0, y0+1, width)
299+		printString(vx, &x, y0, Styled("Help", vaxis.Style{
300+			Foreground: ui.config.Colors.Status,
301+		}))
302+		drawHorizontalLine(vx, x0, y0+1, width)
303 		y0 += 2
304 
305 		lines := []string{
306@@ -649,10 +658,13 @@ func (ui *UI) drawVerticalMemberList(screen tcell.Screen, x0, y0, width, height
307 			"→Message user",
308 		}
309 		for i, line := range lines {
310-			reverse := i*2 == ui.memberClicked
311+			var st vaxis.Style
312+			if i*2 == ui.memberClicked {
313+				st.Attribute |= vaxis.AttrReverse
314+			}
315 			x := x0
316-			printString(screen, &x, y0, Styled(line, tcell.StyleDefault.Reverse(reverse)))
317-			drawHorizontalLine(screen, x0, y0+1, width)
318+			printString(vx, &x, y0, Styled(line, st))
319+			drawHorizontalLine(vx, x0, y0+1, width)
320 			y0 += 2
321 		}
322 		return
323@@ -669,49 +681,66 @@ func (ui *UI) drawVerticalMemberList(screen tcell.Screen, x0, y0, width, height
324 		} else {
325 			memberString = fmt.Sprintf("%d member", len(members))
326 		}
327-		memberString = truncate(memberString, width-1, "\u2026")
328+		memberString = truncate(vx, memberString, width-1, "\u2026")
329 		xMembers := x0 + 1
330-		printString(screen, &xMembers, y0, Styled(memberString, tcell.StyleDefault.Foreground(ui.config.Colors.Status)))
331+		printString(vx, &xMembers, y0, Styled(memberString, vaxis.Style{
332+			Foreground: ui.config.Colors.Status,
333+		}))
334 	}
335 	y0++
336 	height--
337-	drawHorizontalLine(screen, x0, y0, width)
338+	drawHorizontalLine(vx, x0, y0, width)
339 	y0++
340 	height--
341 
342 	padding := 1
343 	for _, m := range members {
344 		if m.Disconnected {
345-			padding = runeWidth(0x274C)
346+			padding = runeWidth(vx, 0x274C)
347 			break
348 		}
349 	}
350 
351 	for i, m := range members[*offset:] {
352-		reverse := i+*offset == ui.memberClicked
353+		var attr vaxis.AttributeMask
354+		if i+*offset == ui.memberClicked {
355+			attr |= vaxis.AttrReverse
356+		}
357 		x := x0
358 		y := y0 + i
359 		if m.Disconnected {
360-			disconnectedSt := tcell.StyleDefault.Foreground(tcell.ColorRed).Reverse(reverse)
361-			printString(screen, &x, y, Styled("\u274C", disconnectedSt))
362+			disconnectedSt := vaxis.Style{
363+				Foreground: ColorRed,
364+				Attribute:  attr,
365+			}
366+			printString(vx, &x, y, Styled("\u274C", disconnectedSt))
367 		} else if m.PowerLevel != "" {
368 			x += padding - 1
369 			powerLevelText := m.PowerLevel[:1]
370-			powerLevelSt := tcell.StyleDefault.Foreground(tcell.ColorGreen).Reverse(reverse)
371-			printString(screen, &x, y, Styled(powerLevelText, powerLevelSt))
372+			powerLevelSt := vaxis.Style{
373+				Foreground: vaxis.IndexColor(2),
374+				Attribute:  attr,
375+			}
376+			printString(vx, &x, y, Styled(powerLevelText, powerLevelSt))
377 		} else {
378 			x += padding
379 		}
380 
381 		var name StyledString
382-		nameText := truncate(m.Name.Name, width-1, "\u2026")
383+		nameText := truncate(vx, m.Name.Name, width-1, "\u2026")
384 		if m.Away {
385-			name = Styled(nameText, tcell.StyleDefault.Foreground(tcell.ColorGray).Reverse(reverse))
386+			name = Styled(nameText, vaxis.Style{
387+				Foreground: ColorGray,
388+				Attribute:  attr,
389+			})
390 		} else {
391 			color := IdentColor(ui.config.Colors.Nicks, m.Name.Name, m.Self)
392-			name = Styled(nameText, tcell.StyleDefault.Foreground(color).Reverse(reverse))
393+			name = Styled(nameText, vaxis.Style{
394+				Foreground: color,
395+				Attribute:  attr,
396+			})
397 		}
398 
399-		printString(screen, &x, y, name)
400+		printString(vx, &x, y, name)
401 	}
402 }