diff options
Diffstat (limited to 'src/display/term.nim')
-rw-r--r-- | src/display/term.nim | 472 |
1 files changed, 472 insertions, 0 deletions
diff --git a/src/display/term.nim b/src/display/term.nim new file mode 100644 index 00000000..ad9368d3 --- /dev/null +++ b/src/display/term.nim @@ -0,0 +1,472 @@ +import math +import options +import os +import tables +import terminal + +import bindings/termcap +import buffer/cell +import config/config +import io/window +import types/color + +#TODO switch from termcap... + +type + TermcapCap = enum + ce # clear till end of line + cd # clear display + cm # cursor move + ti # terminal init (=smcup) + te # terminal end (=rmcup) + so # start standout mode + md # start bold mode + us # start underline mode + mr # start reverse mode + mb # start blink mode + ue # end underline mode + se # end standout mode + me # end all formatting modes + LE # cursor left %1 characters + RI # cursor right %1 characters + + Termcap = ref object + bp: array[1024, uint8] + funcstr: array[256, uint8] + caps: array[TermcapCap, cstring] + + Terminal* = ref TerminalObj + TerminalObj = object + config: Config + infile: File + outfile: File + cleared: bool + canvas: FixedGrid + pcanvas: FixedGrid + attrs*: WindowAttributes + mincontrast: float + colormode: ColorMode + formatmode: FormatMode + smcup: bool + tc: Termcap + tname: string + +func hascap(term: Terminal, c: TermcapCap): bool = term.tc.caps[c] != nil +func cap(term: Terminal, c: TermcapCap): string = $term.tc.caps[c] +func ccap(term: Terminal, c: TermcapCap): cstring = term.tc.caps[c] + +template CSI*(s: varargs[string, `$`]): string = + var r = "\e[" + var first = true + for x in s: + if not first: + r &= ";" + first = false + r &= x + r + +when not termcap_found: + template DECSET(s: varargs[string, `$`]): string = + var r = "\e[?" + var first = true + for x in s: + if not first: + r &= ";" + first = false + r &= x + r & "h" + template DECRST(s: varargs[string, `$`]): string = + var r = "\e[?" + var first = true + for x in s: + if not first: + r &= ";" + first = false + r &= x + r & "l" + template SMCUP(): string = DECSET(1049) + template RMCUP(): string = DECRST(1049) + template HVP(s: varargs[string, `$`]): string = + CSI(s) & "f" + template EL(s: varargs[string, `$`]): string = + CSI(s) & "K" + +template SGR*(s: varargs[string, `$`]): string = + CSI(s) & "m" + +const ANSIColorMap = [ + ColorsRGB["black"], + ColorsRGB["red"], + ColorsRGB["green"], + ColorsRGB["yellow"], + ColorsRGB["blue"], + ColorsRGB["magenta"], + ColorsRGB["cyan"], + ColorsRGB["white"], +] + +var goutfile: File +proc putc(c: char): cint {.cdecl.} = + goutfile.write(c) + +proc write*(term: Terminal, s: string) = + when termcap_found: + discard tputs(cstring(s), cint(s.len), putc) + else: + term.outfile.write(s) + +proc flush*(term: Terminal) = + term.outfile.flushFile() + +proc cursorGoto(term: Terminal, x, y: int): string = + when termcap_found: + return $tgoto(term.ccap cm, cint(x), cint(y)) + else: + return HVP(y, x) + +proc clearEnd(term: Terminal): string = + when termcap_found: + return term.cap ce + else: + return EL() + +proc resetFormat(term: Terminal): string = + when termcap_found: + return term.cap me + else: + return SGR() + +#TODO get rid of these +proc setCursor*(term: Terminal, x, y: int) = + term.write(term.cursorGoto(x, y)) + +proc cursorBackward*(term: Terminal, i: int) = + if i > 0: + if i == 1: + term.write("\b") + else: + when termcap_found: + term.write($tgoto(term.ccap LE, cint(i), 0)) + else: + term.outfile.cursorBackward(i) + +proc cursorForward*(term: Terminal, i: int) = + if i > 0: + when termcap_found: + term.write($tgoto(term.ccap RI, cint(i), 0)) + else: + term.outfile.cursorForward(i) + +proc cursorBegin*(term: Terminal) = + term.write("\r") + +proc enableAltScreen(term: Terminal): string = + when termcap_found: + if term.hascap ti: + term.write($term.cap ti) + else: + return SMCUP() + +proc disableAltScreen(term: Terminal): string = + when termcap_found: + if term.hascap te: + term.write($term.cap te) + else: + return RMCUP() + +proc distance(a, b: CellColor): float = + let a = if a.rgb: + a.rgbcolor + elif a == defaultColor: + ColorsRGB["black"] + else: + ANSIColorMap[a.color mod 10] + let b = if b.rgb: + b.rgbcolor + elif b == defaultColor: + ColorsRGB["white"] + else: + ANSIColorMap[b.color mod 10] + sqrt(float((a.r - b.r) ^ 2 + (a.g - b.b) ^ 2 + (a.g - b.g) ^ 2)) + +proc invert(color: CellColor, bg: bool): CellColor = + if color == defaultColor: + if bg: + return CellColor(rgb: true, rgbcolor: ColorsRGB["white"]) + else: + return CellColor(rgb: true, rgbcolor: ColorsRGB["black"]) + elif color.rgb: + return CellColor(rgb: true, rgbcolor: RGBColor(0xFFFFFF - uint32(color.rgbcolor))) + else: + return CellColor(rgb: true, rgbcolor: RGBColor(0xFFFFFF - uint32(ANSIColorMap[color.color mod 10]))) + +# Use euclidian distance to quantize RGB colors. +proc approximateANSIColor(rgb: RGBColor, exclude = -1): int = + var a = 0 + var n = -1 + for i in 0 .. ANSIColorMap.high: + if i == exclude: continue + let color = ANSIColorMap[i] + if color == rgb: return i + let b = (color.r - rgb.r) ^ 2 + (color.g - rgb.b) ^ 2 + (color.g - rgb.g) ^ 2 + if n == -1 or b < a: + n = i + a = b + return n + +proc processFormat*(term: Terminal, format: var Format, cellf: Format): string = + for flag in FormatFlags: + if flag in term.formatmode: + if flag in format.flags and flag notin cellf.flags: + result &= SGR(FormatCodes[flag].e) + + var cellf = cellf + if term.mincontrast >= 0 and distance(cellf.bgcolor, cellf.fgcolor) <= term.mincontrast: + cellf.fgcolor = invert(cellf.fgcolor, false) + if distance(cellf.bgcolor, cellf.fgcolor) <= term.mincontrast: + cellf.fgcolor = defaultColor + cellf.bgcolor = defaultColor + case term.colormode + of ANSI, EIGHT_BIT: + if cellf.bgcolor.rgb: + let color = approximateANSIColor(cellf.bgcolor.rgbcolor) + if color == 0: # black + cellf.bgcolor = defaultColor + else: + cellf.bgcolor = ColorsANSIBg[color] + if cellf.fgcolor.rgb: + if cellf.bgcolor == defaultColor: + var color = approximateANSIColor(cellf.fgcolor.rgbcolor) + if color == 0: + color = 7 + if color == 7: # white + cellf.fgcolor = defaultColor + else: + cellf.fgcolor = ColorsANSIFg[color] + else: + cellf.fgcolor = if int(cellf.bgcolor.color) - 40 < 4: + defaultColor + else: + ColorsANSIFg[7] + of MONOCHROME: + cellf.fgcolor = defaultColor + cellf.bgcolor = defaultColor + of TRUE_COLOR: discard + + if cellf.fgcolor != format.fgcolor: + var color = cellf.fgcolor + if color.rgb: + let rgb = color.rgbcolor + result &= SGR(38, 2, rgb.r, rgb.g, rgb.b) + elif color == defaultColor: + result &= term.resetFormat() + format = newFormat() + else: + result &= SGR(color.color) + + if cellf.bgcolor != format.bgcolor: + var color = cellf.bgcolor + if color.rgb: + let rgb = color.rgbcolor + result &= SGR(48, 2, rgb.r, rgb.g, rgb.b) + elif color == defaultColor: + result &= term.resetFormat() + format = newFormat() + else: + result &= SGR(color.color) + + for flag in FormatFlags: + if flag in term.formatmode: + if flag notin format.flags and flag in cellf.flags: + result &= SGR(FormatCodes[flag].s) + + format = cellf + +proc windowChange*(term: Terminal, attrs: WindowAttributes) = + term.attrs = attrs + term.canvas = newFixedGrid(attrs.width, attrs.height) + term.cleared = false + +func generateFullOutput(term: Terminal, grid: FixedGrid): string = + var format = newFormat() + result &= term.cursorGoto(0, 0) + result &= term.resetFormat() + for y in 0 ..< grid.height: + for x in 0 ..< grid.width: + let cell = grid[y * grid.width + x] + result &= term.processFormat(format, cell.format) + result &= cell.str + result &= term.clearEnd() + if y != grid.height - 1: + result &= "\r\n" + +func generateSwapOutput(term: Terminal, grid: FixedGrid, prev: FixedGrid): string = + var format = newFormat() + var x = 0 + var line = "" + var lr = false + for i in 0 ..< grid.cells.len: + if x >= grid.width: + format = newFormat() + if lr: + result &= term.cursorGoto(0, i div grid.width - 1) + result &= term.resetFormat() + result &= term.clearEnd() + result &= line + lr = false + x = 0 + line = "" + lr = lr or (grid[i] != prev[i]) + line &= term.processFormat(format, grid.cells[i].format) + line &= grid.cells[i].str + inc x + if lr: + result &= term.cursorGoto(0, grid.height - 1) + result &= term.resetFormat() + result &= term.clearEnd() + result &= line + +proc hideCursor*(term: Terminal) = + term.outfile.hideCursor() + +proc showCursor*(term: Terminal) = + term.outfile.showCursor() + +proc writeGrid*(term: Terminal, grid: FixedGrid, x = 0, y = 0) = + for ly in y ..< y + grid.height: + for lx in x ..< x + grid.width: + term.canvas[ly * term.canvas.width + lx] = grid[(ly - y) * grid.width + (lx - x)] + +proc outputGrid*(term: Terminal) = + term.outfile.write(term.resetFormat()) + if not term.cleared: + term.outfile.write(term.generateFullOutput(term.canvas)) + term.cleared = true + else: + term.outfile.write(term.generateSwapOutput(term.canvas, term.pcanvas)) + term.pcanvas = term.canvas + +when defined(posix): + import posix + import termios + + # see https://viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html + var orig_termios: Termios + var stdin_fileno: FileHandle + proc disableRawMode() {.noconv.} = + discard tcSetAttr(stdin_fileno, TCSAFLUSH, addr orig_termios) + + proc enableRawMode(fileno: FileHandle) = + stdin_fileno = fileno + discard tcGetAttr(fileno, addr orig_termios) + var raw = orig_termios + raw.c_iflag = raw.c_iflag and not (BRKINT or ICRNL or INPCK or ISTRIP or IXON) + raw.c_oflag = raw.c_oflag and not (OPOST) + raw.c_cflag = raw.c_cflag or CS8 + raw.c_lflag = raw.c_lflag and not (ECHO or ICANON or ISIG or IEXTEN) + discard tcSetAttr(fileno, TCSAFLUSH, addr raw) + + var orig_flags: cint + var stdin_unblocked = false + proc unblockStdin*(fileno: FileHandle) = + orig_flags = fcntl(fileno, F_GETFL, 0) + let flags = orig_flags or O_NONBLOCK + discard fcntl(fileno, F_SETFL, flags) + stdin_unblocked = true + + proc restoreStdin*(fileno: FileHandle) = + if stdin_unblocked: + discard fcntl(fileno, F_SETFL, orig_flags) + stdin_unblocked = false +else: + proc disableRawMode() = + discard + + proc enableRawMode(fileno: FileHandle) = + discard + + proc unblockStdin*(): cint = + discard + + proc restoreStdin*(flags: cint) = + discard + +proc isatty*(term: Terminal): bool = + term.infile.isatty() and term.outfile.isatty() + +proc quit*(term: Terminal) = + if term.infile != nil and term.isatty(): + disableRawMode() + if term.smcup: + term.write(term.disableAltScreen()) + else: + term.write(term.cursorGoto(0, term.attrs.height - 1)) + term.showCursor() + term.cleared = false + term.flush() + +when termcap_found: + proc loadTermcap(term: Terminal) = + assert goutfile == nil + goutfile = term.outfile + let tc = new Termcap + if tgetent(cast[cstring](addr tc.bp), cstring(term.tname)) == 1: + term.tc = tc + for id in TermcapCap: + tc.caps[id] = tgetstr(cstring($id), cast[ptr cstring](addr tc.funcstr)) + else: + raise newException(Defect, "Failed to load termcap description for terminal " & term.tname) + +proc detectTermAttributes(term: Terminal) = + term.tname = getEnv("TERM") + if term.tname == "": + term.tname = "dosansi" + when termcap_found: + term.loadTermcap() + if term.tc != nil: + term.smcup = term.hascap(ti) + term.formatmode = {FLAG_ITALIC, FLAG_OVERLINE, FLAG_STRIKE} + if term.hascap(us): + term.formatmode.incl(FLAG_UNDERLINE) + if term.hascap(md): + term.formatmode.incl(FLAG_BOLD) + if term.hascap(mr): + term.formatmode.incl(FLAG_REVERSE) + if term.hascap(mb): + term.formatmode.incl(FLAG_BLINK) + else: + term.smcup = true + term.formatmode = {low(FormatFlags)..high(FormatFlags)} + if term.config.colormode.isSome: + term.colormode = term.config.colormode.get + else: + term.colormode = ANSI + let colorterm = getEnv("COLORTERM") + case colorterm + of "24bit", "truecolor": term.colormode = TRUE_COLOR + if term.config.formatmode.isSome: + term.formatmode = term.config.formatmode.get + if term.config.altscreen.isSome: + term.smcup = term.config.altscreen.get + term.mincontrast = term.config.mincontrast + +proc start*(term: Terminal, infile: File) = + term.infile = infile + if term.isatty(): + enableRawMode(infile.getFileHandle()) + term.detectTermAttributes() + if term.smcup: + term.write(term.enableAltScreen()) + +proc restart*(term: Terminal) = + if term.isatty(): + enableRawMode(term.infile.getFileHandle()) + if term.smcup: + term.write(term.enableAltScreen()) + +proc newTerminal*(outfile: File, config: Config, attrs: WindowAttributes): Terminal = + let term = new Terminal + term.outfile = outfile + term.config = config + term.windowChange(attrs) + return term |