package ui import ( "math" "time" "github.com/gdamore/tcell/v2" "github.com/mattn/go-runewidth" "git.sr.ht/~rjarry/aerc/config" ) // TODO: Attach history providers // TODO: scrolling type TextInput struct { Invalidatable cells int ctx *Context focus bool index int password bool prompt string scroll int text []rune change []func(ti *TextInput) tabcomplete func(s string) []string completions []string completeIndex int completeDelay time.Duration completeDebouncer *time.Timer uiConfig config.UIConfig } // Creates a new TextInput. TextInputs will render a "textbox" in the entire // context they're given, and process keypresses to build a string from user // input. func NewTextInput(text string, ui config.UIConfig) *TextInput { return &TextInput{ cells: -1, text: []rune(text), index: len([]rune(text)), uiConfig: ui, } } func (ti *TextInput) Password(password bool) *TextInput { ti.password = password return ti } func (ti *TextInput) Prompt(prompt string) *TextInput { ti.prompt = prompt return ti } func (ti *TextInput) TabComplete( tabcomplete func(s string) []string, d time.Duration) *TextInput { ti.tabcomplete = tabcomplete ti.completeDelay = d return ti } func (ti *TextInput) String() string { return string(ti.text) } func (ti *TextInput) StringLeft() string { return string(ti.text[:ti.index]) } func (ti *TextInput) StringRight() string { return string(ti.text[ti.index:]) } func (ti *TextInput) Set(value string) *TextInput { ti.text = []rune(value) ti.index = len(ti.text) return ti } func (ti *TextInput) Invalidate() { ti.DoInvalidate(ti) } func (ti *TextInput) Draw(ctx *Context) { scroll := ti.scroll if !ti.focus { scroll = 0 } else { ti.ensureScroll() } ti.ctx = ctx // gross defaultStyle := ti.uiConfig.GetStyle(config.STYLE_DEFAULT) ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle) text := ti.text[scroll:] sindex := ti.index - scroll if ti.password { x := ctx.Printf(0, 0, defaultStyle, "%s", ti.prompt) cells := runewidth.StringWidth(string(text)) ctx.Fill(x, 0, cells, 1, '*', defaultStyle) } else { ctx.Printf(0, 0, defaultStyle, "%s%s", ti.prompt, string(text)) } cells := runewidth.StringWidth(string(text[:sindex]) + ti.prompt) if ti.focus { ctx.SetCursor(cells, 0) ti.drawPopover(ctx) } } func (ti *TextInput) drawPopover(ctx *Context) { if len(ti.completions) == 0 { return } cmp := &completions{ options: ti.completions, idx: ti.completeIndex, stringLeft: ti.StringLeft(), onSelect: func(idx int) { ti.completeIndex = idx ti.Invalidate() }, onExec: func() { ti.executeCompletion() ti.invalidateCompletions() ti.Invalidate() }, onStem: func(stem string) { ti.Set(stem + ti.StringRight()) ti.Invalidate() }, uiConfig: ti.uiConfig, } width := maxLen(ti.completions) + 3 height := len(ti.completions) ctx.Popover(0, 0, width, height, cmp) } func (ti *TextInput) MouseEvent(localX int, localY int, event tcell.Event) { switch event := event.(type) { case *tcell.EventMouse: switch event.Buttons() { case tcell.Button1: if localX >= len(ti.prompt)+1 && localX <= len(ti.text[ti.scroll:])+len(ti.prompt)+1 { ti.index = localX - len(ti.prompt) - 1 ti.ensureScroll() ti.Invalidate() } } } } func (ti *TextInput) Focus(focus bool) { ti.focus = focus if focus && ti.ctx != nil { cells := runewidth.StringWidth(string(ti.text[:ti.index])) ti.ctx.SetCursor(cells+1, 0) } else if !focus && ti.ctx != nil { ti.ctx.HideCursor() } } func (ti *TextInput) ensureScroll() { if ti.ctx == nil { return } w := ti.ctx.Width() - len(ti.prompt) if ti.index >= ti.scroll+w { ti.scroll = ti.index - w + 1 } if ti.index < ti.scroll { ti.scroll = ti.index } } func (ti *TextInput) insert(ch rune) { left := ti.text[:ti.index] right := ti.text[ti.index:] ti.text = append(left, append([]rune{ch}, right...)...) ti.index++ ti.ensureScroll() ti.Invalidate() ti.onChange() } func (ti *TextInput) deleteWord() { // TODO: Break on any of / " ' if len(ti.text) == 0 || ti.index <= 0 { return } i := ti.index - 1 if ti.text[i] == ' ' { i-- } for ; i >= 0; i-- { if ti.text[i] == ' ' { break } } ti.text = append(ti.text[:i+1], ti.text[ti.index:]...) ti.index = i + 1 ti.ensureScroll() ti.Invalidate() ti.onChange() } func (ti *TextInput) deleteLineForward() { if len(ti.text) == 0 || len(ti.text) == ti.index { return } ti.text = ti.text[:ti.index] ti.ensureScroll() ti.Invalidate() ti.onChange() } func (ti *TextInput) deleteLineBackward() { if len(ti.text) == 0 || ti.index == 0 { return } ti.text = ti.text[ti.index:] ti.index = 0 ti.ensureScroll() ti.Invalidate() ti.onChange() } func (ti *TextInput) deleteChar() { if len(ti.text) > 0 && ti.index != len(ti.text) { ti.text = append(ti.text[:ti.index], ti.text[ti.index+1:]...) ti.ensureScroll() ti.Invalidate() ti.onChange() } } func (ti *TextInput) backspace() { if len(ti.text) > 0 && ti.index != 0 { ti.text = append(ti.text[:ti.index-1], ti.text[ti.index:]...) ti.index-- ti.ensureScroll() ti.Invalidate() ti.onChange()