diff options
Diffstat (limited to 'src/tui/light.go')
-rw-r--r-- | src/tui/light.go | 764 |
1 files changed, 764 insertions, 0 deletions
diff --git a/src/tui/light.go b/src/tui/light.go new file mode 100644 index 00000000..1273c8fb --- /dev/null +++ b/src/tui/light.go @@ -0,0 +1,764 @@ +package tui + +import ( + "fmt" + "os" + "strconv" + "strings" + "syscall" + "time" + "unicode/utf8" + + "github.com/junegunn/fzf/src/util" +) + +const ( + defaultWidth = 80 + defaultHeight = 24 + + escPollInterval = 5 +) + +func openTtyIn() *os.File { + in, err := os.OpenFile("/dev/tty", syscall.O_RDONLY, 0) + if err != nil { + panic("Failed to open /dev/tty") + } + return in +} + +// FIXME: Need better handling of non-displayable characters +func (r *LightRenderer) stderr(str string) { + bytes := []byte(str) + runes := []rune{} + for len(bytes) > 0 { + r, sz := utf8.DecodeRune(bytes) + if r == utf8.RuneError || r != '\x1b' && r != '\n' && r < 32 { + runes = append(runes, '?') + } else { + runes = append(runes, r) + } + bytes = bytes[sz:] + } + r.queued += string(runes) +} + +func (r *LightRenderer) csi(code string) { + r.stderr("\x1b[" + code) +} + +func (r *LightRenderer) flush() { + if len(r.queued) > 0 { + fmt.Fprint(os.Stderr, r.queued) + r.queued = "" + } +} + +// Light renderer +type LightRenderer struct { + theme *ColorTheme + mouse bool + forceBlack bool + prevDownTime time.Time + clickY []int + ttyin *os.File + buffer []byte + ostty string + width int + height int + yoffset int + tabstop int + escDelay int + upOneLine bool + queued string + maxHeightFunc func(int) int +} + +type LightWindow struct { + renderer *LightRenderer + colored bool + border bool + top int + left int + width int + height int + posx int + posy int + tabstop int + bg Color +} + +func NewLightRenderer(theme *ColorTheme, forceBlack bool, mouse bool, tabstop int, maxHeightFunc func(int) int) Renderer { + r := LightRenderer{ + theme: theme, + forceBlack: forceBlack, + mouse: mouse, + ttyin: openTtyIn(), + yoffset: -1, + tabstop: tabstop, + upOneLine: false, + maxHeightFunc: maxHeightFunc} + return &r +} + +func (r *LightRenderer) defaultTheme() *ColorTheme { + colors, err := util.ExecCommand("tput colors").Output() + if err == nil && atoi(strings.TrimSpace(string(colors)), 16) > 16 { + return Dark256 + } + return Default16 +} + +func stty(cmd string) string { + out, err := util.ExecCommand("stty " + cmd + " < /dev/tty").Output() + if err != nil { + // Not sure how to handle this + panic("stty " + cmd + ": " + err.Error()) + } + return strings.TrimSpace(string(out)) +} + +func (r *LightRenderer) findOffset() (row int, col int) { + r.csi("6n") + r.flush() + bytes := r.getBytesInternal([]byte{}) + + // ^[[*;*R + if len(bytes) > 5 && bytes[0] == 27 && bytes[1] == 91 && bytes[len(bytes)-1] == 'R' { + nums := strings.Split(string(bytes[2:len(bytes)-1]), ";") + if len(nums) == 2 { + return atoi(nums[0], 0) - 1, atoi(nums[1], 0) - 1 + } + return -1, -1 + } + + // No idea + return -1, -1 +} + +func repeat(s string, times int) string { + if times > 0 { + return strings.Repeat(s, times) + } + return "" +} + +func atoi(s string, defaultValue int) int { + value, err := strconv.Atoi(s) + if err != nil { + return defaultValue + } + return value +} + +func (r *LightRenderer) Init() { + delay := 100 + delayEnv := os.Getenv("ESCDELAY") + if len(delayEnv) > 0 { + num, err := strconv.Atoi(delayEnv) + if err == nil && num >= 0 { + delay = num + } + } + r.escDelay = delay + + r.ostty = stty("-g") + stty("raw") + r.updateTerminalSize() + initTheme(r.theme, r.defaultTheme(), r.forceBlack) + + _, x := r.findOffset() + if x > 0 { + r.upOneLine = true + r.stderr("\n") + } + for i := 1; i < r.MaxY(); i++ { + r.stderr("\n") + r.csi("G") + } + + if r.mouse { + r.csi("?1000h") + } + r.csi(fmt.Sprintf("%dA", r.MaxY()-1)) + r.csi("G") + r.csi("s") + r.yoffset, _ = r.findOffset() +} + +func (r *LightRenderer) updateTerminalSize() { + sizes := strings.Split(stty("size"), " ") + if len(sizes) < 2 { + r.width = defaultWidth + r.height = r.maxHeightFunc(defaultHeight) + } else { + r.width = atoi(sizes[1], defaultWidth) + r.height = r.maxHeightFunc(atoi(sizes[0], defaultHeight)) + } +} + +func (r *LightRenderer) getch(nonblock bool) int { + b := make([]byte, 1) + util.SetNonblock(r.ttyin, nonblock) + _, err := r.ttyin.Read(b) + if err != nil { + return -1 + } + return int(b[0]) +} + +func (r *LightRenderer) getBytes() []byte { + return r.getBytesInternal(r.buffer) +} + +func (r *LightRenderer) getBytesInternal(buffer []byte) []byte { + c := r.getch(false) + + retries := 0 + if c == ESC { + retries = r.escDelay / escPollInterval + } + buffer = append(buffer, byte(c)) + + for { + c = r.getch(true) + if c == -1 { + if retries > 0 { + retries-- + time.Sleep(escPollInterval * time.Millisecond) + continue + } + break + } + retries = 0 + buffer = append(buffer, byte(c)) + } + + return buffer +} + +func (r *LightRenderer) GetChar() Event { + if len(r.buffer) == 0 { + r.buffer = r.getBytes() + } + if len(r.buffer) == 0 { + panic("Empty buffer") + } + + sz := 1 + defer func() { + r.buffer = r.buffer[sz:] + }() + + switch r.buffer[0] { + case CtrlC: + return Event{CtrlC, 0, nil} + case CtrlG: + return Event{CtrlG, 0, nil} + case CtrlQ: + return Event{CtrlQ, 0, nil} + case 127: + return Event{BSpace, 0, nil} + case ESC: + ev := r.escSequence(&sz) + // Second chance + if ev.Type == Invalid { + r.buffer = r.getBytes() + ev = r.escSequence(&sz) + } + return ev + } + + // CTRL-A ~ CTRL-Z + if r.buffer[0] <= CtrlZ { + return Event{int(r.buffer[0]), 0, nil} + } + char, rsz := utf8.DecodeRune(r.buffer) + if char == utf8.RuneError { + return Event{ESC, 0, nil} + } + sz = rsz + return Event{Rune, char, nil} +} + +func (r *LightRenderer) escSequence(sz *int) Event { + if len(r.buffer) < 2 { + return Event{ESC, 0, nil} + } + *sz = 2 + switch r.buffer[1] { + case 13: + return Event{AltEnter, 0, nil} + case 32: + return Event{AltSpace, 0, nil} + case 47: + return Event{AltSlash, 0, nil} + case 98: + return Event{AltB, 0, nil} + case 100: + return Event{AltD, 0, nil} + case 102: + return Event{AltF, 0, nil} + case 127: + return Event{AltBS, 0, nil} + case 91, 79: + if len(r.buffer) < 3 { + return Event{Invalid, 0, nil} + } + *sz = 3 + switch r.buffer[2] { + case 68: + return Event{Left, 0, nil} + case 67: + return Event{Right, 0, nil} + case 66: + return Event{Down, 0, nil} + case 65: + return Event{Up, 0, nil} + case 90: + return Event{BTab, 0, nil} + case 72: + return Event{Home, 0, nil} + case 70: + return Event{End, 0, nil} + case 77: + return r.mouseSequence(sz) + case 80: + return Event{F1, 0, nil} + case 81: + return Event{F2, 0, nil} + case 82: + return Event{F3, 0, nil} + case 83: + return Event{F4, 0, nil} + case 49, 50, 51, 52, 53, 54: + if len(r.buffer) < 4 { + return Event{Invalid, 0, nil} + } + *sz = 4 + switch r.buffer[2] { + case 50: + if len(r.buffer) == 5 && r.buffer[4] == 126 { + *sz = 5 + switch r.buffer[3] { + case 48: + return Event{F9, 0, nil} + case 49: + return Event{F10, 0, nil} + case 51: + return Event{F11, 0, nil} + case 52: + return Event{F12, 0, nil} + } + } + // Bracketed paste mode \e[200~ / \e[201 + if r.buffer[3] == 48 && (r.buffer[4] == 48 || r.buffer[4] == 49) && r.buffer[5] == 126 { + *sz = 6 + return Event{Invalid, 0, nil} + } + return Event{Invalid, 0, nil} // INS + case 51: + return Event{Del, 0, nil} + case 52: + return Event{End, 0, nil} + case 53: + return Event{PgUp, 0, nil} + case 54: + return Event{PgDn, 0, nil} + case 49: + switch r.buffer[3] { + case 126: + return Event{Home, 0, nil} + case 53, 55, 56, 57: + if len(r.buffer) == 5 && r.buffer[4] == 126 { + *sz = 5 + switch r.buffer[3] { + case 53: + return Event{F5, 0, nil} + case 55: + return Event{F6, 0, nil} + case 56: + return Event{F7, 0, nil} + case 57: + return Event{F8, 0, nil} + } + } + return Event{Invalid, 0, nil} + case 59: + if len(r.buffer) != 6 { + return Event{Invalid, 0, nil} + } + *sz = 6 + switch r.buffer[4] { + case 50: + switch r.buffer[5] { + case 68: + return Event{Home, 0, nil} + case 67: + return Event{End, 0, nil} + } + case 53: + switch r.buffer[5] { + case 68: + return Event{SLeft, 0, nil} + case 67: + return Event{SRight, 0, nil} + } + } // r.buffer[4] + } // r.buffer[3] + } // r.buffer[2] + } // r.buffer[2] + } // r.buffer[1] + if r.buffer[1] >= 'a' && r.buffer[1] <= 'z' { + return Event{AltA + int(r.buffer[1]) - 'a', 0, nil} + } + return Event{Invalid, 0, nil} +} + +func (r *LightRenderer) mouseSequence(sz *int) Event { + if len(r.buffer) < 6 || r.yoffset < 0 { + return Event{Invalid, 0, nil} + } + *sz = 6 + switch r.buffer[3] { + case 32, 36, 40, 48, // mouse-down / shift / cmd / ctrl + 35, 39, 43, 51: // mouse-up / shift / cmd / ctrl + mod := r.buffer[3] >= 36 + down := r.buffer[3]%2 == 0 + x := int(r.buffer[4] - 33) + y := int(r.buffer[5]-33) - r.yoffset + double := false + if down { + now := time.Now() + if now.Sub(r.prevDownTime) < doubleClickDuration { + r.clickY = append(r.clickY, y) + } else { + r.clickY = []int{y} + } + r.prevDownTime = now + } else { + if len(r.clickY) > 1 && r.clickY[0] == r.clickY[1] && + time.Now().Sub(r.prevDownTime) < doubleClickDuration { + double = true + } + } + + return Event{Mouse, 0, &MouseEvent{y, x, 0, down, double, mod}} + case 96, 100, 104, 112, // scroll-up / shift / cmd / ctrl + 97, 101, 105, 113: // scroll-down / shift / cmd / ctrl + mod := r.buffer[3] >= 100 + s := 1 - int(r.buffer[3]%2)*2 + x := int(r.buffer[4] - 33) + y := int(r.buffer[5]-33) - r.yoffset + return Event{Mouse, 0, &MouseEvent{y, x, s, false, false, mod}} + } + return Event{Invalid, 0, nil} +} + +func (r *LightRenderer) Pause() { + stty(fmt.Sprintf("%q", r.ostty)) + r.csi("?1049h") + r.flush() +} + +func (r *LightRenderer) Resume() bool { + stty("raw") + r.csi("?1049l") + r.flush() + // Should redraw + return true +} + +func (r *LightRenderer) Clear() { + r.csi("u") + r.csi("J") + r.flush() +} + +func (r *LightRenderer) RefreshWindows(windows []Window) { + r.flush() +} + +func (r *LightRenderer) Refresh() { + r.updateTerminalSize() +} + +func (r *LightRenderer) Close() { + r.csi("u") + r.csi("J") + if r.mouse { + r.csi("?1000l") + } + if r.upOneLine { + r.csi("A") + } + r.flush() + stty(fmt.Sprintf("%q", r.ostty)) +} + +func (r *LightRenderer) MaxX() int { + return r.width +} + +func (r *LightRenderer) MaxY() int { + return r.height +} + +func (r *LightRenderer) DoesAutoWrap() bool { + return true +} + +func (r *LightRenderer) NewWindow(top int, left int, width int, height int, border bool) Window { + w := &LightWindow{ + renderer: r, + colored: r.theme != nil, + border: border, + top: top, + left: left, + width: width, + height: height, + tabstop: r.tabstop, + bg: colDefault} + if r.theme != nil { + w.bg = r.theme.Bg + } + if w.border { + w.drawBorder() + } + return w +} + +func (w *LightWindow) drawBorder() { + w.Move(0, 0) + w.CPrint(ColBorder, AttrRegular, "┌"+repeat("─", w.width-2)+"┐") + for y := 1; y < w.height-1; y++ { + w.Move(y, 0) + w.CPrint(ColBorder, AttrRegular, "│") + w.cprint2(colDefault, w.bg, AttrRegular, repeat(" ", w.width-2)) + w.CPrint(ColBorder, AttrRegular, "│") + } + w.Move(w.height-1, 0) + w.CPrint(ColBorder, AttrRegular, "└"+repeat("─", w.width-2)+"┘") +} + +func (w *LightWindow) csi(code string) { + w.renderer.csi(code) +} + +func (w *LightWindow) stderr(str string) { + w.renderer.stderr(str) +} + +func (w *LightWindow) Top() int { + return w.top +} + +func (w *LightWindow) Left() int { + return w.left +} + +func (w *LightWindow) Width() int { + return w.width +} + +func (w *LightWindow) Height() int { + return w.height +} + +func (w *LightWindow) Refresh() { +} + +func (w *LightWindow) Close() { +} + +func (w *LightWindow) X() int { + return w.posx +} + +func (w *LightWindow) Enclose(y int, x int) bool { + return x >= w.left && x < (w.left+w.width) && + y >= w.top && y < (w.top+w.height) +} + +func (w *LightWindow) Move(y int, x int) { + w.posx = x + w.posy = y + + w.csi("u") + y += w.Top() + if y > 0 { + w.csi(fmt.Sprintf("%dB", y)) + } + x += w.Left() + if x > 0 { + w.csi(fmt.Sprintf("%dC", x)) + } +} + +func (w *LightWindow) MoveAndClear(y int, x int) { + w.Move(y, x) + // We should not delete preview window on the right + // csi("K") + w.Print(repeat(" ", w.width-x)) + w.Move(y, x) +} + +func attrCodes(attr Attr) []string { + codes := []string{} + if (attr & Bold) > 0 { + codes = append(codes, "1") + } + if (attr & Dim) > 0 { + codes = append(codes, "2") + } + if (attr & Italic) > 0 { + codes = append(codes, "3") + } + if (attr & Underline) > 0 { + codes = append(codes, "4") + } + if (attr & Blink) > 0 { + codes = append(codes, "5") + } + if (attr & Reverse) > 0 { + codes = append(codes, "7") + } + return codes +} + +func colorCodes(fg Color, bg Color) []string { + codes := []string{} + appendCode := func(c Color, offset int) { + if c == colDefault { + return + } + if c.is24() { + r := (c >> 16) & 0xff + g := (c >> 8) & 0xff + b := (c) & 0xff + codes = append(codes, fmt.Sprintf("%d;2;%d;%d;%d", 38+offset, r, g, b)) + } else if c >= colBlack && c <= colWhite { + codes = append(codes, fmt.Sprintf("%d", int(c)+30+offset)) + } else if c > colWhite && c < 16 { + codes = append(codes, fmt.Sprintf("%d", int(c)+90+offset-8)) + } else if c >= 16 && c < 256 { + codes = append(codes, fmt.Sprintf("%d;5;%d", 38+offset, c)) + } + } + appendCode(fg, 0) + appendCode(bg, 10) + return codes +} + +func (w *LightWindow) csiColor(fg Color, bg Color, attr Attr) bool { + codes := append(attrCodes(attr), colorCodes(fg, bg)...) + w.csi(";" + strings.Join(codes, ";") + "m") + return len(codes) > 0 +} + +func (w *LightWindow) Print(text string) { + w.cprint2(colDefault, w.bg, AttrRegular, text) +} + +func (w *LightWindow) CPrint(pair ColorPair, attr Attr, text string) { + if !w.colored { + w.csiColor(colDefault, colDefault, attrFor(pair, attr)) + } else { + w.csiColor(pair.Fg(), pair.Bg(), attr) + } + w.stderr(text) + w.csi("m") +} + +func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) { + if w.csiColor(fg, bg, attr) { + defer w.csi("m") + } + w.stderr(text) +} + +type wrappedLine struct { + text string + displayWidth int +} + +func wrapLine(input string, prefixLength int, max int, tabstop int) []wrappedLine { + lines := []wrappedLine{} + width := 0 + line := "" + for _, r := range input { + w := util.Max(util.RuneWidth(r, prefixLength+width, 8), 1) + width += w + str := string(r) + if r == '\t' { + str = repeat(" ", w) + } + if prefixLength+width <= max { + line += str + } else { + lines = append(lines, wrappedLine{string(line), width - w}) + line = str + prefixLength = 0 + width = util.RuneWidth(r, prefixLength, 8) + } + } + lines = append(lines, wrappedLine{string(line), width}) + return lines +} + +func (w *LightWindow) fill(str string, onMove func()) bool { + allLines := strings.Split(str, "\n") + for i, line := range allLines { + lines := wrapLine(line, w.posx, w.width, w.tabstop) + for j, wl := range lines { + w.stderr(wl.text) + w.posx += wl.displayWidth + if j < len(lines)-1 || i < len(allLines)-1 { + if w.posy+1 >= w.height { + return false + } + w.MoveAndClear(w.posy+1, 0) + onMove() + } + } + } + return true +} + +func (w *LightWindow) setBg() { + if w.bg != colDefault { + w.csiColor(colDefault, w.bg, AttrRegular) + } +} + +func (w *LightWindow) Fill(text string) bool { + w.MoveAndClear(w.posy, w.posx) + w.setBg() + return w.fill(text, w.setBg) +} + +func (w *LightWindow) CFill(fg Color, bg Color, attr Attr, text string) bool { + w.MoveAndClear(w.posy, w.posx) + if bg == colDefault { + bg = w.bg + } + if w.csiColor(fg, bg, attr) { + return w.fill(text, func() { w.csiColor(fg, bg, attr) }) + defer w.csi("m") + } + return w.fill(text, w.setBg) +} + +func (w *LightWindow) FinishFill() { + for y := w.posy + 1; y < w.height; y++ { + w.MoveAndClear(y, 0) + } +} + +func (w *LightWindow) Erase() { + if w.border { + w.drawBorder() + } + // We don't erase the window here to avoid flickering during scroll + w.Move(0, 0) +} |