about summary refs log blame commit diff stats
path: root/src/display/term.nim
blob: dd01d2b482ecbf4d47b45e7dffb4dad9cc943e7b (plain) (tree)
1
2
3
4
5
6
7
8
9
           
              
         
             
               
              
 
                       
                  
                    
                
                   
                  
 

                            
    













                                 

                                   





                                    

                             
                  

                 
                 
                     
                      
                            
                    


                          

                 
 


                                                                         










                                                






















                                                    



                         



                                                










                       



                                    
                                        



















                                                    





                                           


                                                                       

                                          


                        


                
















                                                             
                      


                                            





                                              
                                                    





                                             
                                                  





                                   













                                               


                                               
                         
          
                               
         

                                     
 













                                                                                  

















                                                                             

                                                                                

                                                         
                                      












                                                                

                      







                                                             
                         



                                
 
                                                                                 



                                                                         
 





                                               
             








                                               
             



                                

                                                         
                                        


                

                                                             
                                                       
                      
 
                                                                           


                              





                                                                            
                                               
                 
                 
           

                                                                  
                          
                                 
                              
                               
                             
             
                              


                     

                                                       
                                                     

                            
 
                                                                                   
                          
           
           

                
                                
                            


                 
                       
                          
            

                                                          
                      
                                 

                  
           
               
                                   
                                                   
                                                 

         


                                                 
                  






                                  



                                                                         


                                                                













                                                                                                          
 
















                                                        
                                  

                            
                                        

                                                                                                      
                                                            

                       
                                                                          





                                                    
 


                                   
                    


                
                                                                         
                           
                              
                                    

                                                                 
                                          
                         
                                                



                                                                                  
                                                                        
                                                  
 

                             

                                          
                                        
                                         

                          
                                          
                       
                                                

                             





                                          





                                   
                            
                   
                    
                  
                                         
         
                                                           


                        
 
















                                                                                                 












                                                                 
       


                                                             
                    
 
                                           
                      

                                         
                             
                
                                      
 





                                              
                                                                                     


                         
                          
             
import math
import options
import os
import tables
import terminal
import unicode

import bindings/termcap
import buffer/cell
import config/config
import io/window
import utils/twtstr
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: int
    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(): string =
    CSI() & "K"
  template ED(): string =
    CSI() & "J"

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 clearDisplay(term: Terminal): string =
  when termcap_found:
    return term.cap cd
  else:
    return ED()

proc isatty(term: Terminal): bool =
  term.infile != nil and term.infile.isatty() and term.outfile.isatty()

proc resetFormat(term: Terminal): string =
  when termcap_found:
    if term.isatty():
      return term.cap me
    return SGR()
  else:
    return SGR()

proc startFormat(term: Terminal, flag: FormatFlags): string =
  when termcap_found:
    if term.isatty():
      case flag
      of FLAG_BOLD: return term.cap md
      of FLAG_UNDERLINE: return term.cap us
      of FLAG_REVERSE: return term.cap mr
      of FLAG_BLINK: return term.cap mb
      else: discard
  return SGR(FormatCodes[flag].s)

proc endFormat(term: Terminal, flag: FormatFlags): string =
  when termcap_found:
    if flag == FLAG_UNDERLINE and term.isatty():
      return term.cap ue
  return SGR(FormatCodes[flag].e)

#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, 0, cint(i)))
      else:
        term.outfile.cursorBackward(i)

proc cursorForward*(term: Terminal, i: int) =
  if i > 0:
    when termcap_found:
      term.write($tgoto(term.ccap RI, 0, cint(i)))
    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 getRGB(a: CellColor, bg: bool): RGBColor =
  if a.rgb:
    return a.rgbcolor
  elif a == defaultColor:
    if bg:
      return ColorsRGB["black"]
    else:
      return ColorsRGB["white"]
  return ANSIColorMap[a.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

# Return a fgcolor contrasted to the background by contrast.
proc correctContrast(bgcolor, fgcolor: CellColor, contrast: int): CellColor =
  let cfgcolor = fgcolor
  let bgcolor = getRGB(bgcolor, true)
  let fgcolor = getRGB(fgcolor, false)
  let bgY = bgcolor.Y
  let fgY = fgcolor.Y
  let diff = abs(bgY - fgY)
  if diff < contrast:
    let newrgb = if bgY > contrast:
      YUV(bgY - contrast, fgcolor.U, fgcolor.V)
    else:
      YUV(bgY + contrast, fgcolor.U, fgcolor.V)
    if cfgcolor.rgb:
      return newrgb.cellColor()
    return ColorsANSIFg[approximateANSIColor(newrgb)]
  return cfgcolor

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 &= term.endFormat(flag)

  var cellf = cellf
  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

  cellf.fgcolor = correctContrast(cellf.bgcolor, cellf.fgcolor, term.mincontrast)
  if cellf.fgcolor != format.fgcolor and cellf.fgcolor == defaultColor or
      cellf.bgcolor != format.bgcolor and cellf.bgcolor == defaultColor:
    result &= term.resetFormat()
    format = newFormat()

  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:
      discard
    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:
      discard
    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 &= term.startFormat(flag)

  format = cellf

proc windowChange*(term: Terminal, attrs: WindowAttributes) =
  term.attrs = attrs
  term.canvas = newFixedGrid(attrs.width, attrs.height)
  term.cleared = false

proc processOutputString(term: Terminal, str: string, w: var int): string =
  if str.validateUtf8() != -1:
    return "?"
  for r in str.runes():
    let tw = r.twidth(w)
    if r == Rune('\t'):
      # Needs to be replaced with spaces, otherwise bgcolor isn't displayed.
      for i in 0 ..< tw:
        result &= ' '
    elif r.isControlChar():
      result &= "^" & getControlLetter(char(r))
    elif tw != 0:
      result &= r
    w += tw

proc generateFullOutput(term: Terminal, grid: FixedGrid): string =
  var format = newFormat()
  result &= term.cursorGoto(0, 0)
  result &= term.resetFormat()
  result &= term.clearDisplay()
  for y in 0 ..< grid.height:
    var w = 0
    for x in 0 ..< grid.width:
      while w < x:
        result &= " "
        inc w
      let cell = grid[y * grid.width + x]
      result &= term.processFormat(format, cell.format)
      result &= term.processOutputString(cell.str, w)
    if y != grid.height - 1:
      result &= "\r\n"

proc generateSwapOutput(term: Terminal, grid: FixedGrid, prev: FixedGrid): string =
  var format = newFormat()
  var x = 0
  var w = 0
  var line = ""
  var lr = false
  for i in 0 ..< grid.cells.len:
    let cell = grid.cells[i]
    while w < x:
      line &= " "
      inc w
    if x >= grid.width:
      format = newFormat()
      if lr:
        result &= term.cursorGoto(0, i div grid.width - 1)
        result &= term.resetFormat()
        result &= line
        result &= term.clearEnd()
        lr = false
      x = 0
      w = 0
      line = ""
    lr = lr or (grid[i] != prev[i])
    line &= term.processFormat(format, cell.format)
    line &= term.processOutputString(cell.str, w)
    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()

func emulateOverline(term: Terminal): bool =
  term.config.emulateoverline and FLAG_OVERLINE notin term.formatmode and
  FLAG_UNDERLINE in term.formatmode

proc writeGrid*(term: Terminal, grid: FixedGrid, x = 0, y = 0) =
  for ly in y ..< y + grid.height:
    for lx in x ..< x + grid.width:
      let i = ly * term.canvas.width + lx
      term.canvas[i] = grid[(ly - y) * grid.width + (lx - x)]
      if i >= term.canvas.width and FLAG_OVERLINE in term.canvas[i].format.flags and term.emulateOverline:
        let w = grid[(ly - y) * grid.width + (lx - x)].width()
        let s = i - term.canvas.width
        var j = s
        while j < term.canvas.len and j < s + w:
          let cell = addr term.canvas[j]
          cell.format.flags.incl(FLAG_UNDERLINE)
          if cell.str == "":
            cell.str = " "
          if cell.str == " ":
            cell.format.fgcolor = grid[(ly - y) * grid.width + (lx - x)].format.fgcolor
          j += cell[].width()

proc applyConfig(term: Terminal) =
  if term.config.colormode.isSome:
    term.colormode = term.config.colormode.get
  elif term.isatty():
    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
  for fm in FormatFlags:
    if fm in term.config.noformatmode:
      term.formatmode.excl(fm)
  if term.isatty() and term.config.altscreen.isSome:
    term.smcup = term.config.altscreen.get
  term.mincontrast = term.config.mincontrast

proc outputGrid*(term: Terminal) =
  if term.config.termreload:
    term.applyConfig()
  term.outfile.write(term.resetFormat())
  let samesize = term.canvas.width == term.pcanvas.width and term.canvas.height == term.pcanvas.height
  if term.config.forceclear or not term.cleared or not samesize:
    term.outfile.write(term.generateFullOutput(term.canvas))
    term.cleared = true
  else:
    term.outfile.write(term.generateSwapOutput(term.canvas, term.pcanvas))
  if not samesize:
    term.pcanvas.width = term.canvas.width
    term.pcanvas.height = term.canvas.height
    term.pcanvas.cells.setLen(term.canvas.cells.len)
  for i in 0 ..< term.canvas.cells.len:
    term.pcanvas[i] = term.canvas[i]

proc clearCanvas*(term: Terminal) =
  term.cleared = false

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 quit*(term: Terminal) =
  if 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:
    if term.isatty():
      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:
    if term.isatty():
      term.smcup = true
      term.formatmode = {low(FormatFlags)..high(FormatFlags)}
  term.applyConfig()

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