diff options
author | bptato <nincsnevem662@gmail.com> | 2024-03-14 20:57:45 +0100 |
---|---|---|
committer | bptato <nincsnevem662@gmail.com> | 2024-03-14 21:05:16 +0100 |
commit | d26766c4c4015990703e84e8136f96d222edbc97 (patch) | |
tree | 7f412f8ca98d2b04323da5cf2fd607efbd6c408d /src/local | |
parent | a8f05f18fdd64485c26b453e62e8073b50e271ef (diff) | |
download | chawan-d26766c4c4015990703e84e8136f96d222edbc97.tar.gz |
Move around some modules
* extern -> gone, runproc absorbed by pager, others moved into io/ * display -> local/ (where else would we display?) * xhr -> html/ * move out WindowAttributes from term, so we don't depend on local from server
Diffstat (limited to 'src/local')
-rw-r--r-- | src/local/client.nim | 8 | ||||
-rw-r--r-- | src/local/container.nim | 2 | ||||
-rw-r--r-- | src/local/lineedit.nim | 328 | ||||
-rw-r--r-- | src/local/pager.nim | 70 | ||||
-rw-r--r-- | src/local/term.nim | 899 |
5 files changed, 1292 insertions, 15 deletions
diff --git a/src/local/client.nim b/src/local/client.nim index 4f7d9f2b..bc52f704 100644 --- a/src/local/client.nim +++ b/src/local/client.nim @@ -15,11 +15,11 @@ when defined(posix): import bindings/constcharp import bindings/quickjs import config/config -import display/lineedit -import display/term import html/chadombuilder import html/dom import html/event +import html/formdata +import html/xmlhttprequest import io/bufstream import io/posixstream import io/promise @@ -41,7 +41,9 @@ import loader/headers import loader/loader import loader/request import local/container +import local/lineedit import local/pager +import local/term import server/buffer import server/forkserver import types/blob @@ -49,8 +51,6 @@ import types/cookie import types/opt import types/url import utils/twtstr -import xhr/formdata -import xhr/xmlhttprequest import chagashi/charset diff --git a/src/local/container.nim b/src/local/container.nim index 7946200a..e2d84ac0 100644 --- a/src/local/container.nim +++ b/src/local/container.nim @@ -6,7 +6,6 @@ when defined(posix): import std/posix import config/config -import display/term import io/promise import io/serialize import io/socketstream @@ -23,6 +22,7 @@ import types/color import types/cookie import types/referrer import types/url +import types/winattrs import utils/luwrap import utils/mimeguess import utils/strwidth diff --git a/src/local/lineedit.nim b/src/local/lineedit.nim new file mode 100644 index 00000000..0f048586 --- /dev/null +++ b/src/local/lineedit.nim @@ -0,0 +1,328 @@ +import std/strutils +import std/unicode + +import bindings/quickjs +import js/javascript +import types/cell +import types/opt +import types/winattrs +import utils/strwidth +import utils/twtstr + +import chagashi/charset +import chagashi/validator +import chagashi/decoder + +type + LineEditState* = enum + EDIT, FINISH, CANCEL + + LineHistory* = ref object + lines: seq[string] + + LineEdit* = ref object + news*: string + prompt: string + promptw: int + state*: LineEditState + escNext*: bool + cursorx: int # 0 ..< news.notwidth + cursori: int # 0 ..< news.len + shiftx: int # 0 ..< news.notwidth + shifti: int # 0 ..< news.len + padding: int # 0 or 1 + maxwidth: int + disallowed: set[char] + hide: bool + hist: LineHistory + histindex: int + histtmp: string + invalid*: bool + +jsDestructor(LineEdit) + +func newLineHistory*(): LineHistory = + return LineHistory() + +# Note: capped at edit.maxwidth. +func getDisplayWidth(edit: LineEdit): int = + var dispw = 0 + var i = edit.shifti + var r: Rune + while i < edit.news.len and dispw < edit.maxwidth: + fastRuneAt(edit.news, i, r) + dispw += r.width() + return dispw + +proc shiftView(edit: LineEdit) = + # Shift view so it contains the cursor. + if edit.cursorx < edit.shiftx: + edit.shiftx = edit.cursorx + edit.shifti = edit.cursori + # Shift view so it is completely filled. + if edit.shiftx > 0: + let dispw = edit.getDisplayWidth() + if dispw < edit.maxwidth: + let targetx = edit.shiftx - edit.maxwidth + dispw + if targetx <= 0: + edit.shiftx = 0 + edit.shifti = 0 + else: + while edit.shiftx > targetx: + let (r, len) = edit.news.lastRune(edit.shifti - 1) + edit.shiftx -= r.width() + edit.shifti -= len + edit.padding = 0 + # Shift view so it contains the cursor. (act 2) + if edit.shiftx < edit.cursorx - edit.maxwidth: + while edit.shiftx < edit.cursorx - edit.maxwidth and + edit.shifti < edit.news.len: + var r: Rune + fastRuneAt(edit.news, edit.shifti, r) + edit.shiftx += r.width() + if edit.shiftx > edit.cursorx - edit.maxwidth: + # skipped over a cell because of a double-width char + edit.padding = 1 + +proc generateOutput*(edit: LineEdit): FixedGrid = + edit.shiftView() + # Make the output grid +1 cell wide, so it covers the whole input area. + result = newFixedGrid(edit.promptw + edit.maxwidth + 1) + var x = 0 + for r in edit.prompt.runes: + result[x].str &= $r + x += r.width() + if x >= result.width: break + for i in 0 ..< edit.padding: + if x < result.width: + result[x].str = " " + inc x + var i = edit.shifti + while i < edit.news.len: + var r: Rune + fastRuneAt(edit.news, i, r) + if not edit.hide: + let w = r.width() + if x + w > result.width: break + if r.isControlChar(): + result[x].str &= '^' + inc x + result[x].str &= char(r).getControlLetter() + inc x + else: + result[x].str &= $r + x += w + else: + if x + 1 > result.width: break + result[x].str &= '*' + inc x + +proc getCursorX*(edit: LineEdit): int = + return edit.promptw + edit.cursorx + edit.padding - edit.shiftx + +proc insertCharseq(edit: LineEdit, s: string) = + let s = if edit.escNext: + s + else: + deleteChars(s, edit.disallowed) + edit.escNext = false + if s.len == 0: + return + let rem = edit.news.substr(edit.cursori) + edit.news.setLen(edit.cursori) + edit.news &= s + edit.news &= rem + edit.cursori += s.len + edit.cursorx += s.notwidth() + edit.invalid = true + +proc cancel(edit: LineEdit) {.jsfunc.} = + edit.state = CANCEL + +proc submit(edit: LineEdit) {.jsfunc.} = + if edit.hist.lines.len == 0 or edit.news != edit.hist.lines[^1]: + edit.hist.lines.add(edit.news) + edit.state = FINISH + +proc backspace(edit: LineEdit) {.jsfunc.} = + if edit.cursori > 0: + let (r, len) = edit.news.lastRune(edit.cursori - 1) + edit.news.delete(edit.cursori - len .. edit.cursori - 1) + edit.cursori -= len + edit.cursorx -= r.width() + edit.invalid = true + +proc write*(edit: LineEdit, s: string, cs: Charset): bool = + if cs == CHARSET_UTF_8: + if s.validateUTF8Surr() != -1: + return false + edit.insertCharseq(s) + else: + let td = newTextDecoder(cs) + var success = false + let s = td.decodeAll(s, success) + if not success: + return false + edit.insertCharseq(s) + return true + +proc write(edit: LineEdit, s: string): bool {.jsfunc.} = + edit.write(s, CHARSET_UTF_8) + +proc delete(edit: LineEdit) {.jsfunc.} = + if edit.cursori < edit.news.len: + let len = edit.news.runeLenAt(edit.cursori) + edit.news.delete(edit.cursori ..< edit.cursori + len) + edit.invalid = true + +proc escape(edit: LineEdit) {.jsfunc.} = + edit.escNext = true + +proc clear(edit: LineEdit) {.jsfunc.} = + if edit.cursori > 0: + edit.news.delete(0..edit.cursori - 1) + edit.cursori = 0 + edit.cursorx = 0 + edit.invalid = true + +proc kill(edit: LineEdit) {.jsfunc.} = + if edit.cursori < edit.news.len: + edit.news.setLen(edit.cursori) + edit.invalid = true + +proc backward(edit: LineEdit) {.jsfunc.} = + if edit.cursori > 0: + let (r, len) = edit.news.lastRune(edit.cursori - 1) + edit.cursori -= len + edit.cursorx -= r.width() + if edit.cursorx < edit.shiftx: + edit.invalid = true + +proc forward(edit: LineEdit) {.jsfunc.} = + if edit.cursori < edit.news.len: + var r: Rune + fastRuneAt(edit.news, edit.cursori, r) + edit.cursorx += r.width() + if edit.cursorx >= edit.shiftx + edit.maxwidth: + edit.invalid = true + +proc prevWord(edit: LineEdit) {.jsfunc.} = + if edit.cursori == 0: + return + let (r, len) = edit.news.lastRune(edit.cursori - 1) + if r.breaksWord(): + edit.cursori -= len + edit.cursorx -= r.width() + while edit.cursori > 0: + let (r, len) = edit.news.lastRune(edit.cursori - 1) + if r.breaksWord(): + break + edit.cursori -= len + edit.cursorx -= r.width() + if edit.cursorx < edit.shiftx: + edit.invalid = true + +proc nextWord(edit: LineEdit) {.jsfunc.} = + if edit.cursori >= edit.news.len: + return + let oc = edit.cursori + var r: Rune + fastRuneAt(edit.news, edit.cursori, r) + if r.breaksWord(): + edit.cursorx += r.width() + else: + edit.cursori = oc + while edit.cursori < edit.news.len: + let pc = edit.cursori + fastRuneAt(edit.news, edit.cursori, r) + if r.breaksWord(): + edit.cursori = pc + break + edit.cursorx += r.width() + if edit.cursorx >= edit.shiftx + edit.maxwidth: + edit.invalid = true + +proc clearWord(edit: LineEdit) {.jsfunc.} = + let oc = edit.cursori + edit.prevWord() + if oc != edit.cursori: + edit.news.delete(edit.cursori .. oc - 1) + edit.invalid = true + +proc killWord(edit: LineEdit) {.jsfunc.} = + if edit.cursori >= edit.news.len: + return + let oc = edit.cursori + let ox = edit.cursorx + edit.nextWord() + if edit.cursori != oc: + if edit.cursori < edit.news.len: + let len = edit.news.runeLenAt(edit.cursori) + edit.news.delete(oc ..< edit.cursori + len) + else: + edit.news.delete(oc ..< edit.cursori) + edit.cursori = oc + edit.cursorx = ox + edit.invalid = true + +proc begin(edit: LineEdit) {.jsfunc.} = + edit.cursori = 0 + edit.cursorx = 0 + if edit.shiftx > 0: + edit.invalid = true + +proc `end`(edit: LineEdit) {.jsfunc.} = + if edit.cursori < edit.news.len: + edit.cursori = edit.news.len + edit.cursorx = edit.news.notwidth() + if edit.cursorx >= edit.shiftx + edit.maxwidth: + edit.invalid = true + +proc prevHist(edit: LineEdit) {.jsfunc.} = + if edit.histindex > 0: + if edit.news.len > 0: + edit.histtmp = $edit.news + dec edit.histindex + edit.news = edit.hist.lines[edit.histindex] + # The begin call is needed so the cursor doesn't get lost outside + # the string. + edit.begin() + edit.end() + edit.invalid = true + +proc nextHist(edit: LineEdit) {.jsfunc.} = + if edit.histindex + 1 < edit.hist.lines.len: + inc edit.histindex + edit.news = edit.hist.lines[edit.histindex] + edit.begin() + edit.end() + edit.invalid = true + elif edit.histindex < edit.hist.lines.len: + inc edit.histindex + edit.news = edit.histtmp + edit.begin() + edit.end() + edit.histtmp = "" + +proc windowChange*(edit: LineEdit, attrs: WindowAttributes) = + edit.maxwidth = attrs.width - edit.promptw - 1 + +proc readLine*(prompt: string, termwidth: int, current = "", + disallowed: set[char] = {}, hide = false, hist: LineHistory): LineEdit = + result = LineEdit( + prompt: prompt, + promptw: prompt.width(), + news: current, + disallowed: disallowed, + hide: hide, + invalid: true + ) + result.cursori = result.news.len + result.cursorx = result.news.notwidth() + # - 1, so that the cursor always has place + result.maxwidth = termwidth - result.promptw - 1 + result.hist = hist + result.histindex = result.hist.lines.len + +proc addLineEditModule*(ctx: JSContext) = + ctx.registerType(LineEdit) diff --git a/src/local/pager.nim b/src/local/pager.nim index bf9d3168..7502cb00 100644 --- a/src/local/pager.nim +++ b/src/local/pager.nim @@ -16,16 +16,13 @@ import config/chapath import config/config import config/mailcap import config/mimetypes -import display/lineedit -import display/term -import extern/runproc -import extern/stdio -import extern/tempfile import io/bufstream import io/posixstream import io/promise import io/serialize import io/socketstream +import io/stdio +import io/tempfile import io/urlfilter import js/error import js/javascript @@ -37,7 +34,9 @@ import loader/headers import loader/loader import loader/request import local/container +import local/lineedit import local/select +import local/term import server/buffer import server/forkserver import types/cell @@ -47,6 +46,7 @@ import types/opt import types/referrer import types/urimethodmap import types/url +import types/winattrs import utils/strwidth import utils/twtstr @@ -126,7 +126,8 @@ type jsDestructor(Pager) -func attrs(pager: Pager): WindowAttributes = pager.term.attrs +template attrs(pager: Pager): WindowAttributes = + pager.term.attrs func loaderPid(pager: Pager): int64 {.jsfget.} = int64(pager.loader.process) @@ -745,6 +746,59 @@ proc discardTree(pager: Pager, container = none(Container)) {.jsfunc.} = else: pager.alert("Buffer has no children!") +proc c_system(cmd: cstring): cint {.importc: "system", header: "<stdlib.h>".} + +# Run process (without suspending the terminal controller). +proc runProcess(cmd: string): bool = + let wstatus = c_system(cstring(cmd)) + if wstatus == -1: + result = false + else: + result = WIFEXITED(wstatus) and WEXITSTATUS(wstatus) == 0 + if not result: + # Hack. + #TODO this is a very bad idea, e.g. say the editor is writing into the + # file, then receives SIGINT, now the file is corrupted but Chawan will + # happily read it as if nothing happened. + # We should find a proper solution for this. + result = WIFSIGNALED(wstatus) and WTERMSIG(wstatus) == SIGINT + +# Run process (and suspend the terminal controller). +proc runProcess(term: Terminal, cmd: string, wait = false): bool = + term.quit() + result = runProcess(cmd) + if wait: + term.anyKey() + term.restart() + +# Run process, and capture its output. +proc runProcessCapture(cmd: string, outs: var string): bool = + let file = popen(cmd, "r") + if file == nil: + return false + let fs = newFileStream(file) + outs = fs.readAll() + let rv = pclose(file) + if rv == -1: + return false + return rv == 0 + +# Run process, and write an arbitrary string into its standard input. +proc runProcessInto(cmd, ins: string): bool = + let file = popen(cmd, "w") + if file == nil: + return false + let fs = newFileStream(file) + fs.write(ins) + let rv = pclose(file) + if rv == -1: + return false + return rv == 0 + +template myExec(cmd: string) = + discard execl("/bin/sh", "sh", "-c", cstring(cmd), nil) + exitnow(127) + proc toggleSource(pager: Pager) {.jsfunc.} = if not pager.container.canreinterpret: return @@ -1182,7 +1236,6 @@ proc ansiDecode(pager: Pager; url: URL; ishtml: var bool; fdin: cint): cint = discard close(pipefdOutAnsi[1]) closeStderr() myExec(cmd) - assert false else: discard close(pipefdOutAnsi[1]) discard close(fdin) @@ -1206,7 +1259,6 @@ proc runMailcapReadPipe(pager: Pager; stream: SocketStream; cmd: string; closeStderr() discard close(pipefdOut[1]) myExec(cmd) - doAssert false # parent pid @@ -1227,7 +1279,6 @@ proc runMailcapWritePipe(pager: Pager; stream: SocketStream; closeStdout() closeStderr() myExec(cmd) - doAssert false else: # parent stream.close() @@ -1326,7 +1377,6 @@ proc filterBuffer(pager: Pager; stream: SocketStream; cmd: string; closeStderr() discard close(pipefd_out[1]) myExec(cmd) - doAssert false # parent discard close(pipefd_out[1]) let fdout = pipefd_out[0] diff --git a/src/local/term.nim b/src/local/term.nim new file mode 100644 index 00000000..050edf3b --- /dev/null +++ b/src/local/term.nim @@ -0,0 +1,899 @@ +import std/options +import std/os +import std/posix +import std/streams +import std/strutils +import std/tables +import std/termios +import std/unicode + +import bindings/termcap +import config/config +import types/cell +import types/color +import types/opt +import types/winattrs +import utils/strwidth +import utils/twtstr + +import chagashi/charset +import chagashi/encoder +import chagashi/validator + +#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 + ZH # start italic mode + ue # end underline mode + se # end standout mode + me # end all formatting modes + vs # enhance cursor + vi # make cursor invisible + ve # reset cursor to normal + + Termcap = ref object + bp: array[1024, uint8] + funcstr: array[256, uint8] + caps: array[TermcapCap, cstring] + + Terminal* = ref TerminalObj + TerminalObj = object + cs*: Charset + config: Config + infile*: File + outfile: File + cleared: bool + canvas: FixedGrid + pcanvas: FixedGrid + attrs*: WindowAttributes + colormode: ColorMode + formatmode: FormatMode + smcup: bool + tc: Termcap + tname: string + set_title: bool + stdin_unblocked: bool + orig_flags: cint + orig_flags2: cint + orig_termios: Termios + defaultBackground: RGBColor + defaultForeground: RGBColor + +# control sequence introducer +template CSI(s: varargs[string, `$`]): string = + "\e[" & s.join(';') + +# primary device attributes +const DA1 = CSI("c") + +# report xterm text area size in pixels +const GEOMPIXEL = CSI(14, "t") + +# report window size in chars +const GEOMCELL = CSI(18, "t") + +# allow shift-key to override mouse protocol +const XTSHIFTESCAPE = CSI(">0s") + +# device control string +template DCS(a, b: char, s: varargs[string]): string = + "\eP" & a & b & s.join(';') & "\e\\" + +template XTGETTCAP(s: varargs[string, `$`]): string = + DCS('+', 'q', s) + +# OS command +template OSC(s: varargs[string, `$`]): string = + "\e]" & s.join(';') & '\a' + +template XTERM_TITLE(s: string): string = + OSC(0, s) + +const XTGETFG = OSC(10, "?") # get foreground color +const XTGETBG = OSC(11, "?") # get background color + +# DEC set +template DECSET(s: varargs[string, `$`]): string = + "\e[?" & s.join(';') & 'h' + +# DEC reset +template DECRST(s: varargs[string, `$`]): string = + "\e[?" & s.join(';') & 'l' + +# alt screen +const SMCUP = DECSET(1049) +const RMCUP = DECRST(1049) + +# mouse tracking +const SGRMOUSEBTNON = DECSET(1002, 1006) +const SGRMOUSEBTNOFF = DECRST(1002, 1006) + +when not termcap_found: + const CNORM = DECSET(25) + const CIVIS = DECRST(25) + template HVP(s: varargs[string, `$`]): string = + CSI(s) & "f" + template EL(): string = + CSI() & "K" + template ED(): string = + CSI() & "J" + + proc write(term: Terminal, s: string) = + term.outfile.write(s) +else: + 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] + + var goutfile: File + proc putc(c: char): cint {.cdecl.} = + goutfile.write(c) + + proc write(term: Terminal, s: cstring) = + discard tputs(s, 1, putc) + + proc write(term: Terminal, s: string) = + term.write(cstring(s)) + +template SGR*(s: varargs[string, `$`]): string = + CSI(s) & "m" + +#TODO a) this should be customizable b) these defaults sucks +const ANSIColorMap = [ + rgb(0, 0, 0), + rgb(205, 0, 0), + rgb(0, 205, 0), + rgb(205, 205, 0), + rgb(0, 0, 238), + rgb(205, 0, 205), + rgb(0, 205, 205), + rgb(229, 229, 229), + rgb(127, 127, 127), + rgb(255, 0, 0), + rgb(0, 255, 0), + rgb(255, 255, 0), + rgb(92, 92, 255), + rgb(255, 0, 255), + rgb(0, 255, 255), + rgb(255, 255, 255) +] + +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 + 1, x + 1) + +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(fd: FileHandle): cint {.importc: "isatty", header: "<unistd.h>".} +proc isatty*(f: File): bool = + return isatty(f.getFileHandle()) != 0 + +proc isatty*(term: Terminal): bool = + term.infile != nil and term.infile.isatty() and term.outfile.isatty() + +proc anyKey*(term: Terminal) = + if term.isatty(): + term.outfile.write("[Hit any key]") + discard term.infile.readChar() + +proc resetFormat(term: Terminal): string = + when termcap_found: + if term.isatty(): + return term.cap me + 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 + of FLAG_ITALIC: return term.cap ZH + 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) + +proc setCursor*(term: Terminal, x, y: int) = + term.write(term.cursorGoto(x, y)) + +proc enableAltScreen(term: Terminal): string = + when termcap_found: + if term.hascap ti: + return term.cap ti + return SMCUP + +proc disableAltScreen(term: Terminal): string = + when termcap_found: + if term.hascap te: + return term.cap te + return RMCUP + +func mincontrast(term: Terminal): int32 = + return term.config.display.minimum_contrast + +proc getRGB(a: CellColor, termDefault: RGBColor): RGBColor = + case a.t + of ctNone: + return termDefault + of ctANSI: + if a.color >= 16: + return EightBitColor(a.color).toRGB() + return ANSIColorMap[a.color] + of ctRGB: + return a.rgbcolor + +# Use euclidian distance to quantize RGB colors. +proc approximateANSIColor(rgb, termDefault: RGBColor): CellColor = + var a = 0i32 + var n = -1 + for i in -1 .. ANSIColorMap.high: + let color = if i >= 0: + ANSIColorMap[i] + else: + termDefault + if color == rgb: + return if i == -1: defaultColor else: ANSIColor(i).cellColor() + let x = int32(color.r) - int32(rgb.r) + let y = int32(color.g) - int32(rgb.g) + let z = int32(color.b) - int32(rgb.b) + let xx = x * x + let yy = y * y + let zz = z * z + let b = xx + yy + zz + if i == -1 or b < a: + n = i + a = b + return if n == -1: defaultColor else: ANSIColor(n).cellColor() + +# Return a fgcolor contrasted to the background by term.mincontrast. +proc correctContrast(term: Terminal, bgcolor, fgcolor: CellColor): CellColor = + let contrast = term.mincontrast + let cfgcolor = fgcolor + let bgcolor = getRGB(bgcolor, term.defaultBackground) + let fgcolor = getRGB(fgcolor, term.defaultForeground) + let bgY = int(bgcolor.Y) + var fgY = int(fgcolor.Y) + let diff = abs(bgY - fgY) + if diff < contrast: + if bgY > fgY: + fgY = bgY - contrast + if fgY < 0: + fgY = bgY + contrast + if fgY > 255: + fgY = 0 + else: + fgY = bgY + contrast + if fgY > 255: + fgY = bgY - contrast + if fgY < 0: + fgY = 255 + let newrgb = YUV(cast[uint8](fgY), fgcolor.U, fgcolor.V) + case term.colormode + of TRUE_COLOR: + return cellColor(newrgb) + of ANSI: + return approximateANSIColor(newrgb, term.defaultForeground) + of EIGHT_BIT: + return cellColor(newrgb.toEightBit()) + of MONOCHROME: + doAssert false + return cfgcolor + +template ansiSGR(n: uint8, bgmod: int): string = + if n < 8: + SGR(30 + bgmod + n) + else: + SGR(82 + bgmod + n) + +template eightBitSGR(n: uint8, bgmod: int): string = + if n < 16: + ansiSGR(n, bgmod) + else: + SGR(38 + bgmod, 5, n) + +template rgbSGR(rgb: RGBColor, bgmod: int): string = + SGR(38 + bgmod, 2, rgb.r, rgb.g, rgb.b) + +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) + if flag notin format.flags and flag in cellf.flags: + result &= term.startFormat(flag) + var cellf = cellf + case term.colormode + of ANSI: + # quantize + if cellf.bgcolor.t == ctANSI and cellf.bgcolor.color > 15: + cellf.bgcolor = cellf.fgcolor.eightbit.toRGB().cellColor() + if cellf.bgcolor.t == ctRGB: + cellf.bgcolor = approximateANSIColor(cellf.bgcolor.rgbcolor, + term.defaultBackground) + if cellf.fgcolor.t == ctANSI and cellf.fgcolor.color > 15: + cellf.fgcolor = cellf.fgcolor.eightbit.toRGB().cellColor() + if cellf.fgcolor.t == ctRGB: + if cellf.bgcolor.t == ctNone: + cellf.fgcolor = approximateANSIColor(cellf.fgcolor.rgbcolor, + term.defaultForeground) + else: + # ANSI fgcolor + bgcolor at the same time is broken + cellf.fgcolor = defaultColor + # correct + cellf.fgcolor = term.correctContrast(cellf.bgcolor, cellf.fgcolor) + # print + case cellf.fgcolor.t + of ctNone: result &= SGR(39) + of ctANSI: result &= ansiSGR(cellf.fgcolor.color, 0) + else: assert false + case cellf.bgcolor.t + of ctNone: result &= SGR(49) + of ctANSI: result &= ansiSGR(cellf.bgcolor.color, 10) + else: assert false + of EIGHT_BIT: + # quantize + if cellf.bgcolor.t == ctRGB: + cellf.bgcolor = cellf.bgcolor.rgbcolor.toEightBit().cellColor() + if cellf.fgcolor.t == ctRGB: + cellf.fgcolor = cellf.fgcolor.rgbcolor.toEightBit().cellColor() + # correct + cellf.fgcolor = term.correctContrast(cellf.bgcolor, cellf.fgcolor) + # print + case cellf.fgcolor.t + of ctNone: result &= SGR(39) + of ctANSI: result &= eightBitSGR(cellf.fgcolor.color, 0) + of ctRGB: assert false + case cellf.bgcolor.t + of ctNone: result &= SGR(49) + of ctANSI: result &= eightBitSGR(cellf.bgcolor.color, 10) + of ctRGB: assert false + of TRUE_COLOR: + # correct + cellf.fgcolor = term.correctContrast(cellf.bgcolor, cellf.fgcolor) + # print + if cellf.fgcolor != format.fgcolor: + case cellf.fgcolor.t + of ctNone: result &= SGR(39) + of ctANSI: result &= eightBitSGR(cellf.fgcolor.color, 0) + of ctRGB: result &= rgbSGR(cellf.fgcolor.rgbcolor, 0) + if cellf.bgcolor != format.bgcolor: + case cellf.bgcolor.t + of ctNone: result &= SGR(49) + of ctANSI: result &= eightBitSGR(cellf.bgcolor.color, 10) + of ctRGB: result &= rgbSGR(cellf.bgcolor.rgbcolor, 10) + of MONOCHROME: + discard # nothing to do + format = cellf + +proc setTitle*(term: Terminal, title: string) = + if term.set_title: + term.outfile.write(XTERM_TITLE(title.replaceControls())) + +proc enableMouse*(term: Terminal) = + term.write(XTSHIFTESCAPE & SGRMOUSEBTNON) + +proc disableMouse*(term: Terminal) = + term.write(SGRMOUSEBTNOFF) + +proc processOutputString*(term: Terminal, str: string, w: var int): string = + if str.validateUTF8Surr() != -1: + return "?" + # twidth wouldn't work here, the view may start at the nth character. + # pager must ensure tabs are converted beforehand. + w += str.notwidth() + let str = if Controls in str: + str.replaceControls() + else: + str + if term.cs == CHARSET_UTF_8: + # The output encoding matches the internal representation. + return str + else: + # Output is not utf-8, so we must encode it first. + var success = false + return newTextEncoder(term.cs).encodeAll(str, success) + +proc generateFullOutput(term: Terminal, grid: FixedGrid): string = + var format = Format() + result &= term.cursorGoto(0, 0) + result &= term.resetFormat() + result &= term.clearDisplay() + for y in 0 ..< grid.height: + if y != 0: + result &= "\r\n" + 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) + +proc generateSwapOutput(term: Terminal, grid, prev: FixedGrid): string = + var vy = -1 + for y in 0 ..< grid.height: + var w = 0 + var change = false + # scan for changes, and set cx to x of the first change + var cx = 0 + # if there is a change, we have to start from the last x with + # a string (otherwise we might overwrite a double-width char) + var lastx = 0 + for x in 0 ..< grid.width: + let i = y * grid.width + x + if grid[i].str != "": + lastx = x + if grid[i] != prev[i]: + change = true + cx = lastx + w = lastx + break + if change: + if cx == 0 and vy != -1: + while vy < y: + result &= "\r\n" + inc vy + else: + result &= term.cursorGoto(cx, y) + vy = y + result &= term.resetFormat() + var format = Format() + for x in cx ..< grid.width: + while w < x: # if previous cell had no width, catch up with x + result &= ' ' + inc w + let cell = grid[y * grid.width + x] + result &= term.processFormat(format, cell.format) + result &= term.processOutputString(cell.str, w) + if w < grid.width: + result &= term.clearEnd() + +proc hideCursor*(term: Terminal) = + when termcap_found: + term.write(term.ccap vi) + else: + term.write(CIVIS) + +proc showCursor*(term: Terminal) = + when termcap_found: + term.write(term.ccap ve) + else: + term.write(CNORM) + +func emulateOverline(term: Terminal): bool = + term.config.display.emulate_overline 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)] + let isol = FLAG_OVERLINE in term.canvas[i].format.flags + if i >= term.canvas.width and isol 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 == " ": + let i = (ly - y) * grid.width + (lx - x) + cell.format.fgcolor = grid[i].format.fgcolor + j += cell[].width() + +proc applyConfigDimensions(term: Terminal) = + # screen dimensions + if term.attrs.width == 0 or term.config.display.force_columns: + term.attrs.width = int(term.config.display.columns) + if term.attrs.height == 0 or term.config.display.force_lines: + term.attrs.height = int(term.config.display.lines) + if term.attrs.ppc == 0 or term.config.display.force_pixels_per_column: + term.attrs.ppc = int(term.config.display.pixels_per_column) + if term.attrs.ppl == 0 or term.config.display.force_pixels_per_line: + term.attrs.ppl = int(term.config.display.pixels_per_line) + term.attrs.width_px = term.attrs.ppc * term.attrs.width + term.attrs.height_px = term.attrs.ppl * term.attrs.height + +proc applyConfig(term: Terminal) = + # colors, formatting + if term.config.display.color_mode.isSome: + term.colormode = term.config.display.color_mode.get + elif term.isatty(): + let colorterm = getEnv("COLORTERM") + if colorterm in ["24bit", "truecolor"]: + term.colormode = TRUE_COLOR + if term.config.display.format_mode.isSome: + term.formatmode = term.config.display.format_mode.get + for fm in FormatFlags: + if fm in term.config.display.no_format_mode: + term.formatmode.excl(fm) + if term.isatty(): + if term.config.display.alt_screen.isSome: + term.smcup = term.config.display.alt_screen.get + term.set_title = term.config.display.set_title + if term.config.display.default_background_color.isSome: + term.defaultBackground = term.config.display.default_background_color.get + if term.config.display.default_foreground_color.isSome: + term.defaultForeground = term.config.display.default_foreground_color.get + # charsets + if term.config.encoding.display_charset.isSome: + term.cs = term.config.encoding.display_charset.get + else: + term.cs = DefaultCharset + for s in ["LC_ALL", "LC_CTYPE", "LANG"]: + let env = getEnv(s) + if env == "": + continue + let cs = getLocaleCharset(env) + if cs != CHARSET_UNKNOWN: + term.cs = cs + break + term.applyConfigDimensions() + +proc outputGrid*(term: Terminal) = + term.outfile.write(term.resetFormat()) + let samesize = term.canvas.width == term.pcanvas.width and + term.canvas.height == term.pcanvas.height + if term.config.display.force_clear 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 + +# see https://viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html +proc disableRawMode(term: Terminal) = + let fd = term.infile.getFileHandle() + discard tcSetAttr(fd, TCSAFLUSH, addr term.orig_termios) + +proc enableRawMode(term: Terminal) = + let fd = term.infile.getFileHandle() + discard tcGetAttr(fd, addr term.orig_termios) + var raw = term.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(fd, TCSAFLUSH, addr raw) + +proc unblockStdin*(term: Terminal) = + if term.isatty(): + let fd = term.infile.getFileHandle() + term.orig_flags = fcntl(fd, F_GETFL, 0) + let flags = term.orig_flags or O_NONBLOCK + discard fcntl(fd, F_SETFL, flags) + term.stdin_unblocked = true + +proc restoreStdin*(term: Terminal) = + if term.stdin_unblocked: + let fd = term.infile.getFileHandle() + discard fcntl(fd, F_SETFL, term.orig_flags) + term.stdin_unblocked = false + +proc quit*(term: Terminal) = + if term.isatty(): + term.disableRawMode() + if term.smcup: + term.write(term.disableAltScreen()) + else: + term.write(term.cursorGoto(0, term.attrs.height - 1) & + term.resetFormat() & "\n") + if term.config.input.use_mouse: + term.disableMouse() + term.showCursor() + term.cleared = false + if term.stdin_unblocked: + let fd = term.infile.getFileHandle() + term.orig_flags2 = fcntl(fd, F_GETFL, 0) + discard fcntl(fd, F_SETFL, term.orig_flags2 and (not O_NONBLOCK)) + term.stdin_unblocked = false + else: + term.orig_flags2 = -1 + 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) + +type + QueryAttrs = enum + qaAnsiColor, qaRGB, qaSixel + + QueryResult = object + success: bool + attrs: set[QueryAttrs] + fgcolor: Option[RGBColor] + bgcolor: Option[RGBColor] + widthPx: int + heightPx: int + width: int + height: int + +proc queryAttrs(term: Terminal, windowOnly: bool): QueryResult = + const tcapRGB = 0x524742 # RGB supported? + if not windowOnly: + const outs = + XTGETFG & + XTGETBG & + GEOMPIXEL & + GEOMCELL & + XTGETTCAP("524742") & + DA1 + term.outfile.write(outs) + else: + const outs = + GEOMPIXEL & + GEOMCELL & + DA1 + term.outfile.write(outs) + result = QueryResult(success: false, attrs: {}) + while true: + template consume(term: Terminal): char = term.infile.readChar() + template fail = return + template expect(term: Terminal, c: char) = + if term.consume != c: + fail + template expect(term: Terminal, s: string) = + for c in s: + term.expect c + template skip_until(term: Terminal, c: char) = + while (let cc = term.consume; cc != c): + discard + term.expect '\e' + case term.consume + of '[': + # CSI + case (let c = term.consume; c) + of '?': # DA1 + var n = 0 + while (let c = term.consume; c != 'c'): + if c == ';': + case n + of 4: result.attrs.incl(qaSixel) + of 22: result.attrs.incl(qaAnsiColor) + else: discard + n = 0 + else: + n *= 10 + n += decValue(c) + result.success = true + break # DA1 returned; done + of '4', '8': # GEOMPIXEL, GEOMCELL + term.expect ';' + var height = 0 + var width = 0 + while (let c = term.consume; c != ';'): + if (let x = decValue(c); x != -1): + height *= 10 + height += x + else: + fail + while (let c = term.consume; c != 't'): + if (let x = decValue(c); x != -1): + width *= 10 + width += x + else: + fail + if c == '4': # GEOMSIZE + result.widthPx = width + result.heightPx = height + if c == '8': # GEOMCELL + result.width = width + result.height = height + else: fail + of ']': + # OSC + term.expect '1' + let c = term.consume + if c notin {'0', '1'}: fail + term.expect ';' + if term.consume == 'r' and term.consume == 'g' and term.consume == 'b': + term.expect ':' + template eat_color(tc: char): uint8 = + var val = 0u8 + var i = 0 + while (let c = term.consume; c != tc): + let v0 = hexValue(c) + if i > 4 or v0 == -1: fail # wat + let v = uint8(v0) + if i == 0: # 1st place + val = (v shl 4) or v + elif i == 1: # 2nd place + val = (val xor 0xF) or v + # all other places are irrelevant + inc i + val + let r = eat_color '/' + let g = eat_color '/' + let b = eat_color '\a' + if c == '0': + result.fgcolor = some(rgb(r, g, b)) + else: + result.bgcolor = some(rgb(r, g, b)) + else: + # not RGB, give up + term.skip_until '\a' + of 'P': + # DCS + let c = term.consume + if c notin {'0', '1'}: + fail + term.expect "+r" + if c == '1': + var id = 0 + while (let c = term.consume; c != '='): + if c notin AsciiHexDigit: + fail + id *= 0x10 + id += hexValue(c) + term.skip_until '\e' # ST (1) + if id == tcapRGB: + result.attrs.incl(qaRGB) + else: # 0 + term.expect '\e' # ST (1) + term.expect '\\' # ST (2) + else: + fail + +type TermStartResult* = enum + tsrSuccess, tsrDA1Fail + +# when windowOnly, only refresh window size. +proc detectTermAttributes(term: Terminal, windowOnly: bool): TermStartResult = + result = tsrSuccess + term.tname = getEnv("TERM") + if term.tname == "": + term.tname = "dosansi" + if not term.isatty(): + return + let fd = term.infile.getFileHandle() + var win: IOctl_WinSize + if ioctl(cint(fd), TIOCGWINSZ, addr win) != -1: + term.attrs.width = int(win.ws_col) + term.attrs.height = int(win.ws_row) + term.attrs.ppc = int(win.ws_xpixel) div term.attrs.width + term.attrs.ppl = int(win.ws_ypixel) div term.attrs.height + if term.config.display.query_da1: + let r = term.queryAttrs(windowOnly) + if r.success: # DA1 success + if r.width != 0: + term.attrs.width = r.width + if r.widthPx != 0: + term.attrs.ppc = r.widthPx div r.width + if r.height != 0: + term.attrs.height = r.height + if r.heightPx != 0: + term.attrs.ppl = r.heightPx div r.height + if windowOnly: + return + if qaAnsiColor in r.attrs: + term.colormode = ANSI + if qaRGB in r.attrs: + term.colormode = TRUE_COLOR + # just assume the terminal doesn't choke on these. + term.formatmode = {FLAG_STRIKE, FLAG_OVERLINE} + if r.bgcolor.isSome: + term.defaultBackground = r.bgcolor.get + if r.fgcolor.isSome: + term.defaultForeground = r.fgcolor.get + else: + # something went horribly wrong. set result to DA1 fail, pager will + # alert the user + result = tsrDA1Fail + if windowOnly: + return + if term.colormode != TRUE_COLOR: + let colorterm = getEnv("COLORTERM") + if colorterm in ["24bit", "truecolor"]: + term.colormode = TRUE_COLOR + when termcap_found: + term.loadTermcap() + if term.tc != nil: + term.smcup = term.hascap ti + if term.hascap(ZH): + term.formatmode.incl(FLAG_ITALIC) + 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)} + +proc windowChange*(term: Terminal) = + discard term.detectTermAttributes(windowOnly = true) + term.applyConfigDimensions() + term.canvas = newFixedGrid(term.attrs.width, term.attrs.height) + term.cleared = false + +proc start*(term: Terminal, infile: File): TermStartResult = + term.infile = infile + if term.isatty(): + term.enableRawMode() + result = term.detectTermAttributes(windowOnly = false) + if result == tsrDA1Fail: + term.config.display.query_da1 = false + if term.isatty() and term.config.input.use_mouse: + term.enableMouse() + term.applyConfig() + term.canvas = newFixedGrid(term.attrs.width, term.attrs.height) + if term.smcup: + term.write(term.enableAltScreen()) + +proc restart*(term: Terminal) = + if term.isatty(): + term.enableRawMode() + if term.orig_flags2 != -1: + let fd = term.infile.getFileHandle() + discard fcntl(fd, F_SETFL, term.orig_flags2) + term.orig_flags2 = 0 + term.stdin_unblocked = true + if term.config.input.use_mouse: + term.enableMouse() + if term.smcup: + term.write(term.enableAltScreen()) + +proc newTerminal*(outfile: File, config: Config): Terminal = + return Terminal( + outfile: outfile, + config: config, + defaultBackground: ColorsRGB["black"], + defaultForeground: ColorsRGB["white"] + ) |