//go:build tcell || windows package tui import ( "os" "time" "runtime" "github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2/encoding" "github.com/mattn/go-runewidth" "github.com/rivo/uniseg" ) func HasFullscreenRenderer() bool { return true } func asTcellColor(color Color) tcell.Color { value := uint64(tcell.ColorValid) + uint64(color) if color.is24() { value = value | uint64(tcell.ColorIsRGB) } return tcell.Color(value) } func (p ColorPair) style() tcell.Style { style := tcell.StyleDefault return style.Foreground(asTcellColor(p.Fg())).Background(asTcellColor(p.Bg())) } type Attr int32 type TcellWindow struct { color bool preview bool top int left int width int height int normal ColorPair lastX int lastY int moveCursor bool borderStyle BorderStyle } func (w *TcellWindow) Top() int { return w.top } func (w *TcellWindow) Left() int { return w.left } func (w *TcellWindow) Width() int { return w.width } func (w *TcellWindow) Height() int { return w.height } func (w *TcellWindow) Refresh() { if w.moveCursor { _screen.ShowCursor(w.left+w.lastX, w.top+w.lastY) w.moveCursor = false } w.lastX = 0 w.lastY = 0 w.drawBorder() } func (w *TcellWindow) FinishFill() { // NO-OP } const ( Bold Attr = Attr(tcell.AttrBold) Dim = Attr(tcell.AttrDim) Blink = Attr(tcell.AttrBlink) Reverse = Attr(tcell.AttrReverse) Underline = Attr(tcell.AttrUnderline) StrikeThrough = Attr(tcell.AttrStrikeThrough) Italic = Attr(tcell.AttrItalic) ) const ( AttrUndefined = Attr(0) AttrRegular = Attr(1 << 7) AttrClear = Attr(1 << 8) ) func (r *FullscreenRenderer) defaultTheme() *ColorTheme { if _screen.Colors() >= 256 { return Dark256 } return Default16 } var ( _colorToAttribute = []tcell.Color{ tcell.ColorBlack, tcell.ColorRed, tcell.ColorGreen, tcell.ColorYellow, tcell.ColorBlue, tcell.ColorDarkMagenta, tcell.ColorLightCyan, tcell.ColorWhite, } ) func (c Color) Style() tcell.Color { if c <= colDefault { return tcell.ColorDefault } else if c >= colBlack && c <= colWhite { return _colorToAttribute[int(c)] } else { return tcell.Color(c) } } func (a Attr) Merge(b Attr) Attr { return a | b } // handle the following as private members of FullscreenRenderer instance // they are declared here to prevent introducing tcell library in non-windows builds var ( _screen tcell.Screen _prevMouseButton tcell.ButtonMask ) func (r *FullscreenRenderer) initScreen() { s, e := tcell.NewScreen() if e != nil { errorExit(e.Error()) } if e = s.Init(); e != nil { errorExit(e.Error()) } if r.mouse { s.EnableMouse() } else { s.DisableMouse() } _screen = s } func (r *FullscreenRenderer) Init() { if os.Getenv("TERM") == "cygwin" { os.Setenv("TERM", "") } encoding.Register() r.initScreen() initTheme(r.theme, r.defaultTheme(), r.forceBlack) } func (r *FullscreenRenderer) MaxX() int { ncols, _ := _screen.Size() return int(ncols) } func (r *FullscreenRenderer) MaxY() int { _, nlines := _screen.Size() return int(nlines) } func (w *TcellWindow) X() int { return w.lastX } func (w *TcellWindow) Y() int { return w.lastY } func (r *FullscreenRenderer) Clear() { _screen.Sync() _screen.Clear() } func (r *FullscreenRenderer) Refresh() { // noop } func (r *FullscreenRenderer) GetChar() Event { ev := _screen.PollEvent() switch ev := ev.(type) { case *tcell.EventResize: return Event{Resize, 0, nil} // process mouse events: case *tcell.EventMouse: // mouse down events have zeroed buttons, so we can't use them // mouse up event consists of two events, 1. (main) event with modifier and other metadata, 2. event with zeroed buttons // so mouse click is three consecutive events, but the first and last are indistinguishable from movement events (with released buttons) // dragging has same structure, it only repeats the middle (main) event appropriately x, y := ev.Position() mod := ev.Modifiers() != 0 // since we dont have mouse down events (unlike LightRenderer), we need to track state in prevButton prevButton, button := _prevMouseButton, ev.Buttons() _prevMouseButton = button drag := prevButton == button switch { case button&tcell.WheelDown != 0: return Event{Mouse, 0, &MouseEvent{y, x, -1, false, false, false, mod}} case button&tcell.WheelUp != 0: return Event{Mouse, 0, &MouseEvent{y, x, +1, false, false, false, mod}} case button&tcell.Button1 != 0 && !drag: // all potential double click events put their 'line' coordinate in the clickY array // double click event has two conditions, temporal and spatial, the first is checked here now := time.Now() if now.Sub(r.prevDownTime) < doubleClickDuration { r.clickY = append(r.clickY, y) } else { r.clickY = []int{y} } r.prevDownTime = now // detect double clicks (also check for spatial condition) n := len(r.clickY) double := n > 1 && r.clickY[n-2] == r.clickY[n-1] if double { // make sure two consecutive double clicks require four clicks r.clickY = []int{} } // fire single or double click event return Event{Mouse, 0, &MouseEvent{y, x, 0, true, !double, double, mod}} case button&tcell.Button2 != 0 && !drag: return Event{Mouse, 0, &MouseEvent{y, x, 0, false, true, false, mod}} case runtime.GOOS != "windows": // double and single taps on Windows don't quite work due to // the console acting on the events and not allowing us // to consume them. left := button&tcell.Button1 != 0 down := left || button&tcell.Button3 != 0 double := false if down { now := time.Now() if !left { r.clickY = []int{} } else if now.Sub(r.prevDownTime) < doubleClickDuration { r.clickY = append(r.clickY, x) } else { r.clickY = []int{x} 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, left, down, double, mod}} } // process keyboard: case *tcell.EventKey: mods := ev.Modifiers() none := mods == tcell.ModNone alt := (mods & tcell.ModAlt) > 0 ctrl := (mods & tcell.ModCtrl) > 0 shift := (mods & tcell.ModShift) > 0 ctrlAlt := ctrl && alt altShift := alt && shift keyfn := func(r rune) Event { if alt { return CtrlAltKey(r) } return EventType(CtrlA.Int() - 'a' + int(r)).AsEvent() } switch ev.Key() { // section 1: Ctrl+(Alt)+[a-z] case tcell.KeyCtrlA: return keyfn('a') case tcell.KeyCtrlB: return keyfn('b') case tcell.KeyCtrlC: return keyfn('c') case tcell.KeyCtrlD: return keyfn('d') case tcell.KeyCtrlE: return keyfn('e') case tcell.KeyCtrlF: return keyfn('f') case tcell.KeyCtrlG: return keyfn('g') case tcell.KeyCtrlH: switch ev.Rune() { case 0: if ctrl { return Event{BSpace, 0, nil} } case rune(tcell.KeyCtrlH): switch { case ctrl: return keyfn('h') case alt: return Event{AltBS, 0, nil} case none, shift: return Event{BSpace, 0, nil} } } case tcell.KeyCtrlI: return keyfn('i') case tcell.KeyCtrlJ: return keyfn('j') case tcell.KeyCtrlK: return keyfn('k') case tcell.KeyCtrlL: return keyfn('l') case tcell.KeyCtrlM: return keyfn('m') case tcell.KeyCtrlN: return keyfn('n') case tcell.KeyCtrlO: return keyfn('o') case tcell.KeyCtrlP: return keyfn('p') case tcell.KeyCtrlQ: return keyfn('q') case tcell.KeyCtrlR: return keyfn('r') case tcell.KeyCtrlS: return keyfn('s') case tcell.KeyCtrlT: return keyfn('t') case tcell.KeyCtrlU: return keyfn('u') case tcell.KeyCtrlV: return keyfn('v') case tcell.KeyCtrlW: return keyfn('w') case tcell.KeyCtrlX: return keyfn('x') case tcell.KeyCtrlY: return keyfn('y') case tcell.KeyCtrlZ: return keyfn('z') // section 2: Ctrl+[ \]_] case tcell.KeyCtrlSpace: return Event{CtrlSpace, 0, nil} case tcell.KeyCtrlBackslash: return Event{CtrlBackSlash, 0, nil} case tcell.KeyCtrlRightSq: return Event{CtrlRightBracket, 0, nil} case tcell.KeyCtrlCarat: return Event{CtrlCaret, 0, nil} case tcell.KeyCtrlUnderscore: return Event{CtrlSlash, 0, nil} // section 3: (Alt)+Backspace2 case tcell.KeyBackspace2: if alt { return Event{AltBS, 0, nil} } return Event{BSpace, 0, nil} // section 4: (Alt+Shift)+Key(Up|Down|Left|Right) case tcell.KeyUp: if altShift { return Event{AltSUp, 0, nil} } if shift { return Event{SUp, 0, nil} } if alt { return Event{AltUp, 0, nil} } return Event{Up, 0, nil} case tcell.KeyDown: if altShift { return Event{AltSDown, 0, nil} } if shift { return Event{SDown, 0, nil} } if alt { return Event{AltDown, 0, nil} } return Event{Down, 0, nil} case tcell.KeyLeft: if altShift { return Event{AltSLeft, 0, nil} } if shift { return Event{SLeft, 0, nil} } if alt { return Event{AltLeft, 0, nil} } return Event{Left, 0, nil} case tcell.KeyRight: if altShift { return Event{AltSRight, 0, nil} } if shift { return Event{SRight, 0, nil} } if alt { return Event{AltRight, 0, nil} } return Event{Right, 0, nil} // section 5: (Insert|Home|Delete|End|PgUp|PgDn|BackTab|F1-F12) case tcell.KeyInsert: return Event{Insert, 0, nil} case tcell.KeyHome: return Event{Home, 0, nil} case tcell.KeyDelete: return Event{Del, 0, nil} case tcell.KeyEnd: return Event{End, 0, nil} case tcell.KeyPgUp: return Event{PgUp, 0, nil} case tcell.KeyPgDn: return Event{PgDn, 0, nil} case tcell.KeyBacktab: return Event{BTab, 0, nil} case tcell.KeyF1: return Event{F1, 0, nil} case tcell.KeyF2: return Event{F2, 0, nil} case tcell.KeyF3: return Event{F3, 0, nil} case tcell.KeyF4: return Event{F4, 0, nil} case tcell.KeyF5: return Event{F5, 0, nil} case tcell.KeyF6: return Event{F6, 0, nil} case tcell.KeyF7: return Event{F7, 0, nil} case tcell.KeyF8: return Event{F8, 0, nil} case tcell.KeyF9: return Event{F9, 0, nil} case tcell.KeyF10: return Event{F10, 0, nil} case tcell.KeyF11: return Event{F11, 0, nil} case tcell.KeyF12: return Event{F12, 0, nil} // section 6: (Ctrl+Alt)+'rune' case tcell.KeyRune: r := ev.Rune() switch { // translate native key events to ascii control characters case r == ' ' && ctrl: return Event{CtrlSpace, 0, nil} // handle AltGr characters case ctrlAlt: return Event{Rune, r, nil} // dropping modifiers // simple characters (possibly with modifier) case alt: return AltKey(r) default: return Event{Rune, r, nil} } // section 7: Esc case tcell.KeyEsc: return Event{ESC, 0, nil} } } // section 8: Invalid return Event{Invalid, 0, nil} } func (r *FullscreenRenderer) Pause(clear bool) { if clear { _screen.Fini() } } func (r *FullscreenRenderer) Resume(clear bool, sigcont bool) { if clear { r.initScreen() } } func (r *FullscreenRenderer) Close() { _screen.Fini() } func (r *FullscreenRenderer) RefreshWindows(windows []Window) { // TODO for _, w := range windows { w.Refresh() } _screen.Show() } func (r *FullscreenRenderer) NewWindow(top int, left int, width int, height int, preview bool, borderStyle BorderStyle) Window { normal := ColNormal if preview { normal = ColPreview } return &TcellWindow{ color: r.theme.Colored, preview: preview, top: top, left: left, width: width, height: height, normal: normal, borderStyle: borderStyle} } func (w *TcellWindow) Close() { // TODO } func fill(x, y, w, h int, n ColorPair, r rune) { for ly := 0; ly <= h; ly++ { for lx := 0; lx <= w; lx++ { _screen.SetContent(x+lx, y+ly, r, nil, n.style()) } } } func (w *TcellWindow) Erase() { fill(w.left-1, w.top, w.width+1, w.height, w.normal, ' ') } func (w *TcellWindow) 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 *TcellWindow) Move(y int, x int) { w.lastX = x w.lastY = y w.moveCursor = true } func (w *TcellWindow) MoveAndClear(y int, x int) { w.Move(y, x) for i := w.lastX; i < w.width; i++ { _screen.SetContent(i+w.left, w.lastY+w.top, rune(' '), nil, w.normal.style()) } w.lastX = x } func (w *TcellWindow) Print(text string) { w.printString(text, w.normal) } func (w *TcellWindow) printString(text string, pair ColorPair) { lx := 0 a := pair.Attr() style := pair.style() if a&AttrClear == 0 { style = style. Reverse(a&Attr(tcell.AttrReverse) != 0). Underline(a&Attr(tcell.AttrUnderline) != 0). StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0). Italic(a&Attr(tcell.AttrItalic) != 0). Blink(a&Attr(tcell.AttrBlink) != 0). Dim(a&Attr(tcell.AttrDim) != 0) } gr := uniseg.NewGraphemes(text) for gr.Next() { rs := gr.Runes() if len(rs) == 1 { r := rs[0] if r < rune(' ') { // ignore control characters continue } else if r == '\n' { w.lastY++ lx = 0 continue } else if r == '\u000D' { // skip carriage return continue } } var xPos = w.left + w.lastX + lx var yPos = w.top + w.lastY if xPos < (w.left+w.width) && yPos < (w.top+w.height) { _screen.SetContent(xPos, yPos, rs[0], rs[1:], style) } lx += runewidth.StringWidth(string(rs)) } w.lastX += lx } func (w *TcellWindow) CPrint(pair ColorPair, text string) { w.printString(text, pair) } func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn { lx := 0 a := pair.Attr() var style tcell.Style if w.color { style = pair.style() } else { style = w.normal.style() } style = style. Blink(a&Attr(tcell.AttrBlink) != 0). Bold(a&Attr(tcell.AttrBold) != 0). Dim(a&Attr(tcell.AttrDim) != 0). Reverse(a&Attr(tcell.AttrReverse) != 0). Underline(a&Attr(tcell.AttrUnderline) != 0). StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0). Italic(a&Attr(tcell.AttrItalic) != 0) gr := uniseg.NewGraphemes(text) for gr.Next() { rs := gr.Runes() if len(rs) == 1 && rs[0] == '\n' { w.lastY++ w.lastX = 0 lx = 0 continue } // word wrap: xPos := w.left + w.lastX + lx if xPos >= (w.left + w.width) { w.lastY++ w.lastX = 0 lx = 0 xPos = w.left } yPos := w.top + w.lastY if yPos >= (w.top + w.height) { return FillSuspend } _screen.SetContent(xPos, yPos, rs[0], rs[1:], style) lx += runewidth.StringWidth(string(rs)) } w.lastX += lx if w.lastX == w.width { w.lastY++ w.lastX = 0 return FillNextLine } return FillContinue } func (w *TcellWindow) Fill(str string) FillReturn { return w.fillString(str, w.normal) } func (w *TcellWindow) CFill(fg Color, bg Color, a Attr, str string) FillReturn { if fg == colDefault { fg = w.normal.Fg() } if bg == colDefault { bg = w.normal.Bg() } return w.fillString(str, NewColorPair(fg, bg, a)) } func (w *TcellWindow) drawBorder() { shape := w.borderStyle.shape if shape == BorderNone { return } left := w.left right := left + w.width top := w.top bot := top + w.height var style tcell.Style if w.color { if w.preview { style = ColPreviewBorder.style() } else { style = ColBorder.style() } } else { style = w.normal.style() } switch shape { case BorderRounded, BorderSharp, BorderHorizontal, BorderTop: for x := left; x < right; x++ { _screen.SetContent(x, top, w.borderStyle.horizontal, nil, style) } } switch shape { case BorderRounded, BorderSharp, BorderHorizontal, BorderBottom: for x := left; x < right; x++ { _screen.SetContent(x, bot-1, w.borderStyle.horizontal, nil, style) } } switch shape { case BorderRounded, BorderSharp, BorderVertical, BorderLeft: for y := top; y < bot; y++ { _screen.SetContent(left, y, w.borderStyle.vertical, nil, style) } } switch shape { case BorderRounded, BorderSharp, BorderVertical, BorderRight: for y := top; y < bot; y++ { _screen.SetContent(right-1, y, w.borderStyle.vertical, nil, style) } } switch shape { case BorderRounded, BorderSharp: _screen.SetContent(left, top, w.borderStyle.topLeft, nil, style) _screen.SetContent(right-1, top, w.borderStyle.topRight, nil, style) _screen.SetContent(left, bot-1, w.borderStyle.bottomLeft, nil, style) _screen.SetContent(right-1, bot-1, w.borderStyle.bottomRight, nil, style) } }