diff options
Diffstat (limited to 'src/local')
-rw-r--r-- | src/local/client.nim | 643 | ||||
-rw-r--r-- | src/local/container.nim | 1024 | ||||
-rw-r--r-- | src/local/pager.nim | 1189 | ||||
-rw-r--r-- | src/local/select.nim | 306 |
4 files changed, 3162 insertions, 0 deletions
diff --git a/src/local/client.nim b/src/local/client.nim new file mode 100644 index 00000000..c8e6996e --- /dev/null +++ b/src/local/client.nim @@ -0,0 +1,643 @@ +import cstrutils +import nativesockets +import net +import options +import os +import selectors +import streams +import strutils +import tables +import terminal + +when defined(posix): + import posix + +import std/exitprocs + +import bindings/quickjs +import config/config +import css/sheet +import display/term +import html/chadombuilder +import html/dom +import html/event +import io/headers +import io/lineedit +import io/loader +import io/posixstream +import io/promise +import io/request +import io/window +import ips/forkserver +import ips/socketstream +import js/base64 +import js/domexception +import js/error +import js/fromjs +import js/intl +import js/javascript +import js/module +import js/timeout +import js/tojs +import local/container +import local/pager +import types/blob +import types/cookie +import types/url +import utils/opt +import utils/twtstr +import xhr/formdata +import xhr/xmlhttprequest + +import chakasu/charset + +type + Client* = ref object + alive: bool + attrs: WindowAttributes + config {.jsget.}: Config + console {.jsget.}: Console + errormessage: string + fd: int + fdmap: Table[int, Container] + feednext: bool + forkserver: ForkServer + notnum: bool # has a non-numeric character been input already? + jsctx: JSContext + jsrt: JSRuntime + line {.jsget.}: LineEdit + loader: FileLoader + mainproc: Pid + pager {.jsget.}: Pager + precnum: int32 # current number prefix (when vi-numeric-prefix is true) + s: string # current input buffer + selector: Selector[Container] + store {.jsget, jsset.}: Document + timeouts: TimeoutState[Container] + userstyle: CSSStylesheet + + Console = ref object + err: Stream + pager: Pager + container: Container + prev: Container + ibuf: string + tty: File + +jsDestructor(Client) +jsDestructor(Console) + +proc readChar(console: Console): char = + if console.ibuf == "": + try: + return console.tty.readChar() + except EOFError: + quit(1) + result = console.ibuf[0] + console.ibuf = console.ibuf.substr(1) + +proc finalize(client: Client) {.jsfin.} = + if client.jsctx != nil: + free(client.jsctx) + if client.jsrt != nil: + free(client.jsrt) + +proc doRequest(client: Client, req: Request): Response {.jsfunc.} = + return client.loader.doRequest(req) + +proc fetch[T: Request|string](client: Client, req: T, + init = none(RequestInit)): JSResult[FetchPromise] {.jsfunc.} = + let req = ?newRequest(client.jsctx, req, init) + return ok(client.loader.fetch(req)) + +proc interruptHandler(rt: JSRuntime, opaque: pointer): int {.cdecl.} = + let client = cast[Client](opaque) + if client.console == nil or client.console.tty == nil: return + try: + let c = client.console.tty.readChar() + if c == char(3): #C-c + client.console.ibuf = "" + return 1 + else: + client.console.ibuf &= c + except IOError: + discard + return 0 + +proc runJSJobs(client: Client) = + client.jsrt.runJSJobs(client.console.err) + +proc evalJS(client: Client, src, filename: string, module = false): JSValue = + client.pager.term.unblockStdin() + let flags = if module: + JS_EVAL_TYPE_MODULE + else: + JS_EVAL_TYPE_GLOBAL + result = client.jsctx.eval(src, filename, flags) + client.runJSJobs() + client.pager.term.restoreStdin() + +proc evalJSFree(client: Client, src, filename: string) = + JS_FreeValue(client.jsctx, client.evalJS(src, filename)) + +proc command0(client: Client, src: string, filename = "<command>", + silence = false, module = false) = + let ret = client.evalJS(src, filename, module = module) + if JS_IsException(ret): + client.jsctx.writeException(client.console.err) + else: + if not silence: + let str = fromJS[string](client.jsctx, ret) + if str.isSome: + client.console.err.write(str.get & '\n') + client.console.err.flush() + JS_FreeValue(client.jsctx, ret) + +proc command(client: Client, src: string) = + client.command0(src) + client.console.container.requestLines().then(proc() = + client.console.container.cursorLastLine()) + +proc suspend(client: Client) {.jsfunc.} = + client.pager.term.quit() + discard kill(client.mainproc, cint(SIGSTOP)) + client.pager.term.restart() + +proc quit(client: Client, code = 0) {.jsfunc.} = + if client.alive: + client.alive = false + client.pager.quit() + let ctx = client.jsctx + var global = JS_GetGlobalObject(ctx) + JS_FreeValue(ctx, global) + if client.jsctx != nil: + free(client.jsctx) + #TODO + #if client.jsrt != nil: + # free(client.jsrt) + quit(code) + +proc feedNext(client: Client) {.jsfunc.} = + client.feednext = true + +proc alert(client: Client, msg: string) {.jsfunc.} = + client.pager.alert(msg) + +proc handlePagerEvents(client: Client) = + let container = client.pager.container + if container != nil: + client.pager.handleEvents(container) + +proc evalAction(client: Client, action: string, arg0: int32) = + let ret = client.evalJS(action, "<command>") + let ctx = client.jsctx + if JS_IsFunction(ctx, ret): + if arg0 != 0: + var arg0 = toJS(ctx, arg0) + JS_FreeValue(ctx, JS_Call(ctx, ret, JS_UNDEFINED, 1, addr arg0)) + JS_FreeValue(ctx, arg0) + else: # no precnum + JS_FreeValue(ctx, JS_Call(ctx, ret, JS_UNDEFINED, 0, nil)) + JS_FreeValue(ctx, ret) + +# The maximum number we are willing to accept. +# This should be fine for 32-bit signed ints (which precnum currently is). +# We can always increase it further (e.g. by switching to uint32, uint64...) if +# it proves to be too low. +const MaxPrecNum = 100000000 + +proc handleCommandInput(client: Client, c: char) = + if client.config.input.vi_numeric_prefix and not client.notnum: + if client.precnum != 0 and c == '0' or c in '1' .. '9': + if client.precnum < MaxPrecNum: # better ignore than eval... + client.precnum *= 10 + client.precnum += cast[int32](decValue(c)) + return + else: + client.notnum = true + client.s &= c + let action = getNormalAction(client.config, client.s) + client.evalAction(action, client.precnum) + if not client.feedNext: + client.precnum = 0 + client.notnum = false + client.handlePagerEvents() + client.pager.refreshStatusMsg() + +proc input(client: Client) = + client.pager.term.restoreStdin() + while true: + let c = client.console.readChar() + if client.pager.askpromise != nil: + if c == 'y': + client.pager.fulfillAsk(true) + client.runJSJobs() + elif c == 'n': + client.pager.fulfillAsk(false) + client.runJSJobs() + elif client.pager.lineedit.isSome: + client.s &= c + let edit = client.pager.lineedit.get + client.line = edit + if edit.escNext: + edit.escNext = false + if edit.write(client.s, client.pager.term.cs): + client.s = "" + else: + let action = getLinedAction(client.config, client.s) + if action == "": + if edit.write(client.s, client.pager.term.cs): + client.s = "" + else: + client.feedNext = true + elif not client.feednext: + client.evalAction(action, 0) + if client.pager.lineedit.isNone: + client.line = nil + if not client.feedNext: + client.pager.updateReadLine() + else: + client.handleCommandInput(c) + if not client.feednext: + client.s = "" + break + else: + client.feednext = false + client.s = "" + +proc setTimeout[T: JSValue|string](client: Client, handler: T, + timeout = 0i32): int32 {.jsfunc.} = + return client.timeouts.setTimeout(handler, timeout) + +proc setInterval[T: JSValue|string](client: Client, handler: T, + interval = 0i32): int32 {.jsfunc.} = + return client.timeouts.setInterval(handler, interval) + +proc clearTimeout(client: Client, id: int32) {.jsfunc.} = + client.timeouts.clearTimeout(id) + +proc clearInterval(client: Client, id: int32) {.jsfunc.} = + client.timeouts.clearInterval(id) + +let SIGWINCH {.importc, header: "<signal.h>", nodecl.}: cint + +proc log(console: Console, ss: varargs[string]) {.jsfunc.} = + for i in 0..<ss.len: + console.err.write(ss[i]) + if i != ss.high: + console.err.write(' ') + console.err.write('\n') + console.err.flush() + +proc show(console: Console) {.jsfunc.} = + if console.pager.container != console.container: + console.prev = console.pager.container + console.pager.setContainer(console.container) + console.container.requestLines() + +proc hide(console: Console) {.jsfunc.} = + if console.pager.container == console.container: + console.pager.setContainer(console.prev) + +proc buffer(console: Console): Container {.jsfget.} = + return console.container + +proc acceptBuffers(client: Client) = + while client.pager.unreg.len > 0: + let (pid, stream) = client.pager.unreg.pop() + let fd = stream.source.getFd() + if int(fd) in client.fdmap: + client.selector.unregister(fd) + client.fdmap.del(int(fd)) + else: + client.pager.procmap.del(pid) + stream.close() + for pid, container in client.pager.procmap: + let stream = connectSocketStream(pid, buffered = false, blocking = true) + container.setStream(stream) + let fd = stream.source.getFd() + client.fdmap[int(fd)] = container + client.selector.registerHandle(fd, {Read}, nil) + client.pager.handleEvents(container) + client.pager.procmap.clear() + +proc c_setvbuf(f: File, buf: pointer, mode: cint, size: csize_t): cint {. + importc: "setvbuf", header: "<stdio.h>", tags: [].} + +proc handleRead(client: Client, fd: int) = + if client.console.tty != nil and fd == client.console.tty.getFileHandle(): + client.input() + client.handlePagerEvents() + elif fd == client.forkserver.estream.fd: + var nl = false + const prefix = "STDERR: " + var s = prefix + while true: + try: + let c = client.forkserver.estream.readChar() + if nl and s.len > prefix.len: + client.console.err.write(s) + s = prefix + nl = false + s &= c + nl = c == '\n' + except IOError: + break + if s.len > prefix.len: + client.console.err.write(s) + client.console.err.flush() + elif fd in client.loader.connecting: + client.loader.onConnected(fd) + client.runJSJobs() + elif fd in client.loader.ongoing: + client.loader.onRead(fd) + elif fd in client.loader.unregistered: + discard # ignore + else: + let container = client.fdmap[fd] + client.pager.handleEvent(container) + +proc flushConsole*(client: Client) {.jsfunc.} = + client.handleRead(client.forkserver.estream.fd) + +proc handleError(client: Client, fd: int) = + if client.console.tty != nil and fd == client.console.tty.getFileHandle(): + #TODO do something here... + stderr.write("Error in tty\n") + quit(1) + elif fd == client.forkserver.estream.fd: + #TODO do something here... + stderr.write("Fork server crashed :(\n") + quit(1) + elif fd in client.loader.connecting: + #TODO handle error? + discard + elif fd in client.loader.ongoing: + client.loader.onError(fd) + elif fd in client.loader.unregistered: + discard # already unregistered... + else: + if fd in client.fdmap: + let container = client.fdmap[fd] + if container != client.console.container: + client.console.log("Error in buffer", $container.location) + else: + client.console.container = nil + client.selector.unregister(fd) + client.fdmap.del(fd) + if client.console.container != nil: + client.console.show() + else: + doAssert false + +proc inputLoop(client: Client) = + let selector = client.selector + discard c_setvbuf(client.console.tty, nil, IONBF, 0) + selector.registerHandle(int(client.console.tty.getFileHandle()), {Read}, nil) + let sigwinch = selector.registerSignal(int(SIGWINCH), nil) + while true: + let events = client.selector.select(-1) + for event in events: + if Read in event.events: + client.handleRead(event.fd) + if Error in event.events: + client.handleError(event.fd) + if Signal in event.events: + assert event.fd == sigwinch + client.attrs = getWindowAttributes(client.console.tty) + client.pager.windowChange(client.attrs) + if selectors.Event.Timer in event.events: + assert client.timeouts.runTimeoutFd(event.fd) + client.runJSJobs() + client.console.container.requestLines().then(proc() = + client.console.container.cursorLastLine()) + client.loader.unregistered.setLen(0) + client.acceptBuffers() + if client.pager.scommand != "": + client.command(client.pager.scommand) + client.pager.scommand = "" + client.handlePagerEvents() + if client.pager.container == nil: + # No buffer to display. + quit(1) + client.pager.showAlerts() + client.pager.draw() + +func hasSelectFds(client: Client): bool = + return not client.timeouts.empty or + client.pager.numload > 0 or + client.loader.connecting.len > 0 or + client.loader.ongoing.len > 0 or + client.pager.procmap.len > 0 + +proc headlessLoop(client: Client) = + while client.hasSelectFds(): + let events = client.selector.select(-1) + for event in events: + if Read in event.events: + client.handleRead(event.fd) + if Error in event.events: + client.handleError(event.fd) + if selectors.Event.Timer in event.events: + assert client.timeouts.runTimeoutFd(event.fd) + client.runJSJobs() + client.loader.unregistered.setLen(0) + client.acceptBuffers() + +proc clientLoadJSModule(ctx: JSContext, module_name: cstring, + opaque: pointer): JSModuleDef {.cdecl.} = + let global = JS_GetGlobalObject(ctx) + JS_FreeValue(ctx, global) + var x: Option[URL] + if module_name.startsWith("/") or module_name.startsWith("./") or + module_name.startsWith("../"): + let cur = getCurrentDir() + x = parseURL($module_name, parseURL("file://" & cur & "/")) + else: + x = parseURL($module_name) + if x.isNone or x.get.scheme != "file": + JS_ThrowTypeError(ctx, "Invalid URL: %s", module_name) + return nil + try: + let f = readFile($x.get.path) + return finishLoadModule(ctx, f, module_name) + except IOError: + JS_ThrowTypeError(ctx, "Failed to open file %s", module_name) + return nil + +proc readBlob(client: Client, path: string): Option[WebFile] {.jsfunc.} = + try: + return some(newWebFile(path)) + except IOError: + discard + +#TODO this is dumb +proc readFile(client: Client, path: string): string {.jsfunc.} = + try: + return readFile(path) + except IOError: + discard + +#TODO ditto +proc writeFile(client: Client, path: string, content: string) {.jsfunc.} = + writeFile(path, content) + +proc newConsole(pager: Pager, tty: File): Console = + new(result) + if tty != nil: + var pipefd: array[0..1, cint] + if pipe(pipefd) == -1: + raise newException(Defect, "Failed to open console pipe.") + let url = newURL("javascript:console.show()") + result.container = pager.readPipe0(some("text/plain"), CHARSET_UNKNOWN, + pipefd[0], option(url.get(nil)), "Browser console") + var f: File + if not open(f, pipefd[1], fmWrite): + raise newException(Defect, "Failed to open file for console pipe.") + result.err = newFileStream(f) + result.err.writeLine("Type (M-c) console.hide() to return to buffer mode.") + result.err.flush() + result.pager = pager + result.tty = tty + pager.registerContainer(result.container) + else: + result.err = newFileStream(stderr) + +proc dumpBuffers(client: Client) = + client.headlessLoop() + let ostream = newFileStream(stdout) + for container in client.pager.containers: + try: + client.pager.drawBuffer(container, ostream) + client.pager.handleEvents(container) + except IOError: + client.console.log("Error in buffer", $container.location) + # check for errors + client.handleRead(client.forkserver.estream.fd) + quit(1) + stdout.close() + +proc launchClient*(client: Client, pages: seq[string], + contentType: Option[string], cs: Charset, dump: bool) = + var tty: File + var dump = dump + if not dump: + if stdin.isatty(): + tty = stdin + if stdout.isatty(): + if tty == nil: + dump = not open(tty, "/dev/tty", fmRead) + else: + dump = true + let selector = newSelector[Container]() + let efd = int(client.forkserver.estream.fd) + selector.registerHandle(efd, {Read}, nil) + client.loader.registerFun = proc(fd: int) = + selector.registerHandle(fd, {Read}, nil) + client.loader.unregisterFun = proc(fd: int) = + selector.unregister(fd) + client.selector = selector + client.pager.launchPager(tty) + client.console = newConsole(client.pager, tty) + #TODO passing console.err here makes it impossible to change it later. maybe + # better associate it with jsctx + client.timeouts = newTimeoutState(client.selector, client.jsctx, + client.console.err, proc(src, file: string) = client.evalJSFree(src, file)) + client.alive = true + addExitProc((proc() = client.quit())) + if client.config.start.startup_script != "": + let s = if fileExists(client.config.start.startup_script): + readFile(client.config.start.startup_script) + else: + client.config.start.startup_script + let ismodule = client.config.start.startup_script.endsWith(".mjs") + client.command0(s, client.config.start.startup_script, silence = true, + module = ismodule) + client.userstyle = client.config.css.stylesheet.parseStylesheet() + + if not stdin.isatty(): + client.pager.readPipe(contentType, cs, stdin.getFileHandle()) + + for page in pages: + client.pager.loadURL(page, ctype = contentType, cs = cs) + client.pager.showAlerts() + client.acceptBuffers() + if not dump: + client.inputLoop() + else: + client.dumpBuffers() + if client.config.start.headless: + client.headlessLoop() + client.quit() + +proc nimGCStats(client: Client): string {.jsfunc.} = + return GC_getStatistics() + +proc jsGCStats(client: Client): string {.jsfunc.} = + return client.jsrt.getMemoryUsage() + +proc nimCollect(client: Client) {.jsfunc.} = + GC_fullCollect() + +proc jsCollect(client: Client) {.jsfunc.} = + JS_RunGC(client.jsrt) + +proc sleep(client: Client, millis: int) {.jsfunc.} = + sleep millis + +proc atob(client: Client, data: string): DOMResult[string] {.jsfunc.} = + return atob(data) + +proc btoa(client: Client, data: string): DOMResult[string] {.jsfunc.} = + return btoa(data) + +proc addJSModules(client: Client, ctx: JSContext) = + ctx.addDOMExceptionModule() + ctx.addCookieModule() + ctx.addURLModule() + ctx.addEventModule() + ctx.addDOMModule() + ctx.addHTMLModule() + ctx.addIntlModule() + ctx.addBlobModule() + ctx.addFormDataModule() + ctx.addXMLHttpRequestModule() + ctx.addHeadersModule() + ctx.addRequestModule() + ctx.addResponseModule() + ctx.addLineEditModule() + ctx.addConfigModule() + ctx.addPagerModule() + ctx.addContainerModule() + +func getClient(client: Client): Client {.jsfget: "client".} = + return client + +proc newClient*(config: Config, forkserver: ForkServer, mainproc: Pid): Client = + setControlCHook(proc() {.noconv.} = quit(1)) + let jsrt = newJSRuntime() + JS_SetModuleLoaderFunc(jsrt, normalizeModuleName, clientLoadJSModule, nil) + let jsctx = jsrt.newJSContext() + let attrs = getWindowAttributes(stdout) + let client = Client( + config: config, + forkserver: forkserver, + mainproc: mainproc, + attrs: attrs, + loader: forkserver.newFileLoader( + defaultHeaders = config.getDefaultHeaders(), + proxy = config.getProxy(), + acceptProxy = true + ), + jsrt: jsrt, + jsctx: jsctx, + pager: newPager(config, attrs, forkserver, mainproc, jsctx) + ) + jsrt.setInterruptHandler(interruptHandler, cast[pointer](client)) + var global = JS_GetGlobalObject(jsctx) + jsctx.registerType(Client, asglobal = true) + setGlobal(jsctx, global, client) + JS_FreeValue(jsctx, global) + jsctx.registerType(Console) + client.addJSModules(jsctx) + return client diff --git a/src/local/container.nim b/src/local/container.nim new file mode 100644 index 00000000..5941bf6c --- /dev/null +++ b/src/local/container.nim @@ -0,0 +1,1024 @@ +import deques +import options +import streams +import unicode + +when defined(posix): + import posix + +import buffer/buffer +import buffer/cell +import config/config +import io/promise +import io/request +import io/window +import ips/forkserver +import ips/serialize +import js/javascript +import js/regex +import local/select +import types/buffersource +import types/color +import types/cookie +import types/url +import utils/mimeguess +import utils/twtstr + +import chakasu/charset + +type + CursorPosition* = object + cursorx*: int + cursory*: int + xend*: int + fromx*: int + fromy*: int + setx: int + setxrefresh: bool + + ContainerEventType* = enum + NO_EVENT, FAIL, SUCCESS, NEEDS_AUTH, REDIRECT, ANCHOR, NO_ANCHOR, UPDATE, + READ_LINE, READ_AREA, OPEN, INVALID_COMMAND, STATUS, ALERT, LOADED, TITLE, + CHECK_MAILCAP, QUIT + + ContainerEvent* = object + case t*: ContainerEventType + of READ_LINE: + prompt*: string + value*: string + password*: bool + of READ_AREA: + tvalue*: string + of OPEN, REDIRECT: + request*: Request + of ANCHOR, NO_ANCHOR: + anchor*: string + of ALERT: + msg*: string + of UPDATE: + force*: bool + else: discard + + Highlight* = ref object + x*, y*: int + endy*, endx*: int + rect*: bool + clear*: bool + + Container* = ref object + parent* {.jsget.}: Container + children* {.jsget.}: seq[Container] + config*: BufferConfig + iface*: BufferInterface + width* {.jsget.}: int + height* {.jsget.}: int + title*: string # used in status msg + hovertext: array[HoverType, string] + lastpeek: HoverType + source*: BufferSource + pos: CursorPosition + bpos: seq[CursorPosition] + highlights: seq[Highlight] + process* {.jsget.}: Pid + loadinfo*: string + lines: SimpleFlexibleGrid + lineshift: int + numLines*: int + replace*: Container + code*: int + retry*: seq[URL] + hlon*: bool # highlight on? + sourcepair*: Container # pointer to buffer with a source view (may be nil) + redraw*: bool + needslines*: bool + canceled: bool + events*: Deque[ContainerEvent] + startpos: Option[CursorPosition] + hasstart: bool + redirectdepth*: int + select*: Select + +jsDestructor(Container) + +proc newBuffer*(forkserver: ForkServer, mainproc: Pid, config: BufferConfig, + source: BufferSource, title = "", redirectdepth = 0): Container = + let attrs = getWindowAttributes(stdout) + let ostream = forkserver.ostream + let istream = forkserver.istream + ostream.swrite(FORK_BUFFER) + ostream.swrite(source) + ostream.swrite(config) + ostream.swrite(attrs) + ostream.swrite(mainproc) + ostream.flush() + var process: Pid + istream.sread(process) + return Container( + source: source, + width: attrs.width, + height: attrs.height - 1, + title: title, + config: config, + redirectdepth: redirectdepth, + process: process, + pos: CursorPosition( + setx: -1 + ) + ) + +func charset*(container: Container): Charset = + return container.source.charset + +func contentType*(container: Container): Option[string] {.jsfget.} = + return container.source.contenttype + +func location*(container: Container): URL {.jsfget.} = + return container.source.location + +func lineLoaded(container: Container, y: int): bool = + return y - container.lineshift in 0..container.lines.high + +func getLine(container: Container, y: int): SimpleFlexibleLine = + if container.lineLoaded(y): + return container.lines[y - container.lineshift] + +iterator ilines*(container: Container, slice: Slice[int]): SimpleFlexibleLine {.inline.} = + for y in slice: + yield container.getLine(y) + +func cursorx*(container: Container): int {.inline.} = container.pos.cursorx +func cursory*(container: Container): int {.inline.} = container.pos.cursory +func fromx*(container: Container): int {.inline.} = container.pos.fromx +func fromy*(container: Container): int {.inline.} = container.pos.fromy +func xend(container: Container): int {.inline.} = container.pos.xend +func lastVisibleLine(container: Container): int = min(container.fromy + container.height, container.numLines) - 1 + +func currentLine(container: Container): string = + return container.getLine(container.cursory).str + +func cursorBytes(container: Container, y: int, cc = container.cursorx): int = + let line = container.getLine(y).str + var w = 0 + var i = 0 + while i < line.len and w < cc: + var r: Rune + fastRuneAt(line, i, r) + w += r.twidth(w) + return i + +func currentCursorBytes(container: Container, cc = container.cursorx): int = + return container.cursorBytes(container.cursory, cc) + +# Returns the X position of the first cell occupied by the character the cursor +# currently points to. +func cursorFirstX(container: Container): int = + if container.numLines == 0: return 0 + let line = container.currentLine + var w = 0 + var i = 0 + var r: Rune + let cc = container.cursorx + while i < line.len: + fastRuneAt(line, i, r) + let tw = r.twidth(w) + if w + tw > cc: + return w + w += tw + +# Returns the X position of the last cell occupied by the character the cursor +# currently points to. +func cursorLastX(container: Container): int = + if container.numLines == 0: return 0 + let line = container.currentLine + var w = 0 + var i = 0 + var r: Rune + let cc = container.cursorx + while i < line.len and w <= cc: + fastRuneAt(line, i, r) + w += r.twidth(w) + return max(w - 1, 0) + +# Last cell for tab, first cell for everything else (e.g. double width.) +# This is needed because moving the cursor to the 2nd cell of a double +# width character clears it on some terminals. +func cursorDispX(container: Container): int = + if container.numLines == 0: return 0 + let line = container.currentLine + if line.len == 0: return 0 + var w = 0 + var pw = 0 + var i = 0 + var r: Rune + let cc = container.cursorx + while i < line.len and w <= cc: + fastRuneAt(line, i, r) + pw = w + w += r.twidth(w) + if r == Rune('\t'): + return max(w - 1, 0) + else: + return pw + +func acursorx*(container: Container): int = + max(0, container.cursorDispX() - container.fromx) + +func acursory*(container: Container): int = + container.cursory - container.fromy + +func maxScreenWidth(container: Container): int = + for line in container.ilines(container.fromy..container.lastVisibleLine): + result = max(line.str.width(), result) + +func getTitle*(container: Container): string {.jsfunc.} = + if container.title != "": + return container.title + return container.source.location.serialize(excludepassword = true) + +func currentLineWidth(container: Container): int = + if container.numLines == 0: return 0 + return container.currentLine.width() + +func maxfromy(container: Container): int = max(container.numLines - container.height, 0) + +func maxfromx(container: Container): int = max(container.maxScreenWidth() - container.width, 0) + +func atPercentOf*(container: Container): int = + if container.numLines == 0: return 100 + return (100 * (container.cursory + 1)) div container.numLines + +func lineWindow(container: Container): Slice[int] = + if container.numLines == 0: # not loaded + return 0..container.height * 5 + let n = (container.height * 5) div 2 + var x = container.fromy - n + container.height div 2 + var y = container.fromy + n + container.height div 2 + if y >= container.numLines: + x -= y - container.numLines + y = container.numLines + if x < 0: + y += -x + x = 0 + return x .. y + +func contains*(hl: Highlight, x, y: int): bool = + if hl.rect: + let rx = hl.x .. hl.endx + let ry = hl.y .. hl.endy + return x in rx and y in ry + else: + return (y > hl.y or y == hl.y and x >= hl.x) and + (y < hl.endy or y == hl.endy and x <= hl.endx) + +func contains*(hl: Highlight, y: int): bool = + return y in hl.y .. hl.endy + +func colorArea*(hl: Highlight, y: int, limitx: Slice[int]): Slice[int] = + if hl.rect: + if y in hl.y .. hl.endy: + return max(hl.x, limitx.a) .. min(hl.endx, limitx.b) + else: + if y in hl.y + 1 .. hl.endy - 1: + return limitx + if y == hl.y and y == hl.endy: + return max(hl.x, limitx.a) .. min(hl.endx, limitx.b) + if y == hl.y: + return max(hl.x, limitx.a) .. limitx.b + if y == hl.endy: + return limitx.a .. min(hl.endx, limitx.b) + +func findHighlights*(container: Container, y: int): seq[Highlight] = + for hl in container.highlights: + if y in hl: + result.add(hl) + +func getHoverText*(container: Container): string = + for t in HoverType: + if container.hovertext[t] != "": + return container.hovertext[t] + +func isHoverURL*(container: Container, url: URL): bool = + let hoverurl = parseURL(container.hovertext[HOVER_LINK]) + return hoverurl.isSome and url.host == hoverurl.get.host + +proc triggerEvent(container: Container, event: ContainerEvent) = + container.events.addLast(event) + +proc triggerEvent(container: Container, t: ContainerEventType) = + container.triggerEvent(ContainerEvent(t: t)) + +proc updateCursor(container: Container) + +proc setNumLines(container: Container, lines: int, finish = false) = + if container.numLines != lines: + container.numLines = lines + if container.startpos.isSome and finish: + container.pos = container.startpos.get + container.startpos = none(CursorPosition) + container.updateCursor() + container.triggerEvent(STATUS) + +proc requestLines*(container: Container, w = container.lineWindow): auto {.discardable.} = + return container.iface.getLines(w).then(proc(res: tuple[numLines: int, lines: seq[SimpleFlexibleLine]]) = + container.lines.setLen(w.len) + container.lineshift = w.a + for y in 0 ..< min(res.lines.len, w.len): + container.lines[y] = res.lines[y] + container.lines[y].str.mnormalize() + container.updateCursor() + if res.numLines != container.numLines: + container.setNumLines(res.numLines, true) + let cw = container.fromy ..< container.fromy + container.height + if w.a in cw or w.b in cw or cw.a in w or cw.b in w: + container.triggerEvent(UPDATE)) + +proc redraw(container: Container) {.jsfunc.} = + container.triggerEvent(ContainerEvent(t: UPDATE, force: true)) + +proc sendCursorPosition*(container: Container) = + container.iface.updateHover(container.cursorx, container.cursory) + .then(proc(res: UpdateHoverResult) = + if res.link.isSome: + container.hovertext[HOVER_LINK] = res.link.get + if res.title.isSome: + container.hovertext[HOVER_TITLE] = res.title.get + if res.link.isSome or res.title.isSome: + container.triggerEvent(STATUS) + if res.repaint: + container.needslines = true) + +proc setFromY(container: Container, y: int) {.jsfunc.} = + if container.pos.fromy != y: + container.pos.fromy = max(min(y, container.maxfromy), 0) + container.needslines = true + container.triggerEvent(UPDATE) + +proc setFromX(container: Container, x: int, refresh = true) {.jsfunc.} = + if container.pos.fromx != x: + container.pos.fromx = max(min(x, container.maxfromx), 0) + if container.pos.fromx > container.cursorx: + container.pos.cursorx = min(container.pos.fromx, container.currentLineWidth()) + if refresh: + container.sendCursorPosition() + container.triggerEvent(UPDATE) + +proc setFromXY(container: Container, x, y: int) {.jsfunc.} = + container.setFromY(y) + container.setFromX(x) + +proc setCursorX(container: Container, x: int, refresh = true, save = true) {.jsfunc.} = + if not container.lineLoaded(container.cursory): + container.pos.setx = x + container.pos.setxrefresh = refresh + return + container.pos.setx = -1 + let cw = container.currentLineWidth() + let x2 = x + let x = max(min(x, cw - 1), 0) + if not refresh or container.fromx <= x and x < container.fromx + container.width: + container.pos.cursorx = x + elif refresh and container.fromx > x: + if x2 < container.cursorx: + container.setFromX(x, false) + container.pos.cursorx = container.fromx + elif x > container.cursorx: + container.setFromX(max(x - container.width + 1, container.fromx), false) + container.pos.cursorx = x + elif x < container.cursorx: + container.setFromX(x, false) + container.pos.cursorx = x + if refresh: + container.sendCursorPosition() + if save: + container.pos.xend = container.cursorx + +proc restoreCursorX(container: Container) {.jsfunc.} = + let x = clamp(container.currentLineWidth() - 1, 0, container.xend) + container.setCursorX(x, false, false) + +proc setCursorY(container: Container, y: int, refresh = true) {.jsfunc.} = + let y = max(min(y, container.numLines - 1), 0) + if container.cursory == y: return + if y - container.fromy >= 0 and y - container.height < container.fromy: + container.pos.cursory = y + else: + if y > container.cursory: + container.setFromY(y - container.height + 1) + else: + container.setFromY(y) + container.pos.cursory = y + container.restoreCursorX() + if refresh: + container.sendCursorPosition() + +proc centerLine(container: Container) {.jsfunc.} = + container.setFromY(container.cursory - container.height div 2) + +proc centerColumn(container: Container) {.jsfunc.} = + container.setFromX(container.cursorx - container.width div 2) + +proc setCursorXY(container: Container, x, y: int, refresh = true) {.jsfunc.} = + let fy = container.fromy + container.setCursorY(y, refresh) + container.setCursorX(x, refresh) + if fy != container.fromy: + container.centerLine() + +proc cursorDown(container: Container, n = 1) {.jsfunc.} = + if container.select.open: + container.select.cursorDown() + else: + container.setCursorY(container.cursory + n) + +proc cursorUp(container: Container, n = 1) {.jsfunc.} = + if container.select.open: + container.select.cursorUp() + else: + container.setCursorY(container.cursory - n) + +proc cursorLeft(container: Container, n = 1) {.jsfunc.} = + if container.select.open: + container.select.cursorLeft() + else: + container.setCursorX(container.cursorFirstX() - n) + +proc cursorRight(container: Container, n = 1) {.jsfunc.} = + if container.select.open: + container.select.cursorRight() + else: + container.setCursorX(container.cursorLastX() + n) + +proc cursorLineBegin(container: Container) {.jsfunc.} = + container.setCursorX(0) + +proc cursorLineTextStart(container: Container) {.jsfunc.} = + if container.numLines == 0: return + var x = 0 + for r in container.currentLine.runes: + if not r.isWhitespace(): + break + x += r.twidth(x) + container.setCursorX(x) + +proc cursorLineEnd(container: Container) {.jsfunc.} = + container.setCursorX(container.currentLineWidth() - 1) + +proc cursorNextWord(container: Container) {.jsfunc.} = + if container.numLines == 0: return + var r: Rune + var b = container.currentCursorBytes() + var x = container.cursorx + while b < container.currentLine.len: + let pb = b + fastRuneAt(container.currentLine, b, r) + if r.breaksWord(): + b = pb + break + x += r.twidth(x) + + while b < container.currentLine.len: + let pb = b + fastRuneAt(container.currentLine, b, r) + if not r.breaksWord(): + b = pb + break + x += r.twidth(x) + + if b < container.currentLine.len: + container.setCursorX(x) + else: + if container.cursory < container.numLines - 1: + container.cursorDown() + container.cursorLineBegin() + else: + container.cursorLineEnd() + +proc cursorPrevWord(container: Container) {.jsfunc.} = + if container.numLines == 0: return + var b = container.currentCursorBytes() + var x = container.cursorx + if container.currentLine.len > 0: + b = min(b, container.currentLine.len - 1) + while b >= 0: + let (r, o) = lastRune(container.currentLine, b) + if r.breaksWord(): + break + b -= o + x -= r.twidth(x) + + while b >= 0: + let (r, o) = lastRune(container.currentLine, b) + if not r.breaksWord(): + break + b -= o + x -= r.twidth(x) + else: + b = -1 + + if b >= 0: + container.setCursorX(x) + else: + if container.cursory > 0: + container.cursorUp() + container.cursorLineEnd() + else: + container.cursorLineBegin() + +proc pageDown(container: Container) {.jsfunc.} = + container.setFromY(container.fromy + container.height) + container.setCursorY(container.cursory + container.height) + container.restoreCursorX() + +proc pageUp(container: Container) {.jsfunc.} = + container.setFromY(container.fromy - container.height) + container.setCursorY(container.cursory - container.height) + container.restoreCursorX() + +proc pageLeft(container: Container) {.jsfunc.} = + container.setFromX(container.fromx - container.width) + +proc pageRight(container: Container) {.jsfunc.} = + container.setFromX(container.fromx + container.width) + +proc halfPageUp(container: Container) {.jsfunc.} = + container.setFromY(container.fromy - container.height div 2 + 1) + container.setCursorY(container.cursory - container.height div 2 + 1) + container.restoreCursorX() + +proc halfPageDown(container: Container) {.jsfunc.} = + container.setFromY(container.fromy + container.height div 2 - 1) + container.setCursorY(container.cursory + container.height div 2 - 1) + container.restoreCursorX() + +proc cursorFirstLine(container: Container) {.jsfunc.} = + if container.select.open: + container.select.cursorFirstLine() + else: + container.setCursorY(0) + +proc cursorLastLine*(container: Container) {.jsfunc.} = + if container.select.open: + container.select.cursorLastLine() + else: + container.setCursorY(container.numLines - 1) + +proc cursorTop(container: Container) {.jsfunc.} = + container.setCursorY(container.fromy) + +proc cursorMiddle(container: Container) {.jsfunc.} = + container.setCursorY(container.fromy + (container.height - 2) div 2) + +proc cursorBottom(container: Container) {.jsfunc.} = + container.setCursorY(container.fromy + container.height - 1) + +proc cursorLeftEdge(container: Container) {.jsfunc.} = + container.setCursorX(container.fromx) + +proc cursorMiddleColumn(container: Container) {.jsfunc.} = + container.setCursorX(container.fromx + (container.width - 2) div 2) + +proc cursorRightEdge(container: Container) {.jsfunc.} = + container.setCursorX(container.fromx + container.width - 1) + +proc scrollDown(container: Container) {.jsfunc.} = + if container.fromy + container.height < container.numLines: + container.setFromY(container.fromy + 1) + if container.fromy > container.cursory: + container.cursorDown() + else: + container.cursorDown() + +proc scrollUp(container: Container) {.jsfunc.} = + if container.fromy > 0: + container.setFromY(container.fromy - 1) + if container.fromy + container.height <= container.cursory: + container.cursorUp() + else: + container.cursorUp() + +proc scrollRight(container: Container) {.jsfunc.} = + if container.fromx + container.width < container.maxScreenWidth(): + container.setFromX(container.fromx + 1) + +proc scrollLeft(container: Container) {.jsfunc.} = + if container.fromx > 0: + container.setFromX(container.fromx - 1) + +proc alert(container: Container, msg: string) = + container.triggerEvent(ContainerEvent(t: ALERT, msg: msg)) + +proc lineInfo(container: Container) {.jsfunc.} = + container.alert("line " & $(container.cursory + 1) & "/" & + $container.numLines & " (" & $container.atPercentOf() & "%) col " & + $(container.cursorx + 1) & "/" & $container.currentLineWidth & + " (byte " & $container.currentCursorBytes & ")") + +proc updateCursor(container: Container) = + if container.pos.setx > -1: + container.setCursorX(container.pos.setx, container.pos.setxrefresh) + if container.fromy > container.maxfromy: + container.setFromY(container.maxfromy) + if container.cursory >= container.numLines: + container.setCursorY(container.lastVisibleLine) + container.alert("Last line is #" & $container.numLines) + +proc gotoLine*[T: string|int](container: Container, s: T) = + when s is string: + if s == "": + redraw(container) + elif s[0] == '^': + container.cursorFirstLine() + elif s[0] == '$': + container.cursorLastLine() + else: + let i = parseUInt32(s) + if i.isSome and i.get > 0: + container.setCursorY(int(i.get - 1)) + else: + container.alert("First line is #1") # :) + else: + container.setCursorY(s - 1) + +proc pushCursorPos*(container: Container) = + if container.select.open: + container.select.pushCursorPos() + else: + container.bpos.add(container.pos) + +proc popCursorPos*(container: Container, nojump = false) = + if container.select.open: + container.select.popCursorPos(nojump) + else: + container.pos = container.bpos.pop() + if not nojump: + container.updateCursor() + container.sendCursorPosition() + container.needslines = true + +proc copyCursorPos*(container, c2: Container) = + container.startpos = some(c2.pos) + container.hasstart = true + +proc cursorNextLink*(container: Container) {.jsfunc.} = + container.iface + .findNextLink(container.cursorx, container.cursory) + .then(proc(res: tuple[x, y: int]) = + if res.x > -1 and res.y != -1: + container.setCursorXY(res.x, res.y)) + +proc cursorPrevLink*(container: Container) {.jsfunc.} = + container.iface + .findPrevLink(container.cursorx, container.cursory) + .then(proc(res: tuple[x, y: int]) = + if res.x > -1 and res.y != -1: + container.setCursorXY(res.x, res.y)) + +proc clearSearchHighlights*(container: Container) = + for i in countdown(container.highlights.high, 0): + if container.highlights[i].clear: + container.highlights.del(i) + +proc onMatch(container: Container, res: BufferMatch, refresh: bool) = + if res.success: + container.setCursorXY(res.x, res.y, refresh) + if container.hlon: + container.clearSearchHighlights() + let ex = res.x + res.str.twidth(res.x) - 1 + let hl = Highlight(x: res.x, y: res.y, endx: ex, endy: res.y, clear: true) + container.highlights.add(hl) + container.triggerEvent(UPDATE) + container.hlon = false + elif container.hlon: + container.clearSearchHighlights() + container.triggerEvent(UPDATE) + container.needslines = true + container.hlon = false + +proc cursorNextMatch*(container: Container, regex: Regex, wrap, refresh: bool): + EmptyPromise {.discardable.} = + if container.select.open: + container.select.cursorNextMatch(regex, wrap) + return newResolvedPromise() + else: + return container.iface + .findNextMatch(regex, container.cursorx, container.cursory, wrap) + .then(proc(res: BufferMatch) = + container.onMatch(res, refresh)) + +proc cursorPrevMatch*(container: Container, regex: Regex, wrap, refresh: bool): + EmptyPromise {.discardable.} = + if container.select.open: + container.select.cursorPrevMatch(regex, wrap) + return newResolvedPromise() + else: + return container.iface + .findPrevMatch(regex, container.cursorx, container.cursory, wrap) + .then(proc(res: BufferMatch) = + container.onMatch(res, refresh)) + +proc setLoadInfo(container: Container, msg: string) = + container.loadinfo = msg + container.triggerEvent(STATUS) + +#TODO TODO TODO this should be called with a timeout. +proc onload*(container: Container, res: LoadResult) = + if container.canceled: + container.setLoadInfo("") + #TODO we wouldn't need the then part if we had incremental rendering of + # HTML. + container.iface.cancel().then(proc(lines: int) = + container.setNumLines(lines) + container.needslines = true) + else: + if res.bytes == -1 or res.atend: + container.setLoadInfo("") + elif not res.atend: + container.setLoadInfo(convert_size(res.bytes) & " loaded") + if res.lines > container.numLines: + container.setNumLines(res.lines) + container.triggerEvent(STATUS) + container.needslines = true + if not res.atend: + discard container.iface.load().then(proc(res: LoadResult) = + container.onload(res)) + else: + container.iface.getTitle().then(proc(title: string): auto = + if title != "": + container.title = title + container.triggerEvent(TITLE) + return container.iface.render() + ).then(proc(lines: int): auto = + container.setNumLines(lines, true) + container.needslines = true + container.triggerEvent(LOADED) + if not container.hasstart and container.source.location.anchor != "": + return container.iface.gotoAnchor() + ).then(proc(res: tuple[x, y: int]) = + if res.x != -1 and res.y != -1: + container.setCursorXY(res.x, res.y)) + +proc load(container: Container) = + container.setLoadInfo("Connecting to " & container.location.host & "...") + container.iface.connect().then(proc(res: ConnectResult) = + let info = container.loadinfo + if not res.invalid: + container.code = res.code + if res.code == 0: + container.triggerEvent(SUCCESS) + # accept cookies + if res.cookies.len > 0 and container.config.cookiejar != nil: + container.config.cookiejar.add(res.cookies) + if res.referrerpolicy.isSome and container.config.referer_from: + container.config.referrerpolicy = res.referrerpolicy.get + container.setLoadInfo("Connected to " & $container.source.location & ". Downloading...") + if res.needsAuth: + container.triggerEvent(NEEDS_AUTH) + if res.redirect != nil: + container.triggerEvent(ContainerEvent(t: REDIRECT, request: res.redirect)) + container.source.charset = res.charset + if res.contentType == "application/octet-stream": + let contentType = guessContentType(container.location.pathname, + "application/octet-stream", container.config.mimeTypes) + if contentType != "application/octet-stream": + container.iface.setContentType(contentType) + container.source.contenttype = some(contentType) + elif res.contentType != "": + container.source.contenttype = some(res.contentType) + container.triggerEvent(CHECK_MAILCAP) + else: + container.setLoadInfo("") + container.triggerEvent(FAIL) + else: + container.setLoadInfo(info) + ) + +proc startload*(container: Container) = + container.iface.load() + .then(proc(res: tuple[atend: bool, lines, bytes: int]) = + container.onload(res)) + +proc connect2*(container: Container): EmptyPromise = + return container.iface.connect2() + +proc redirectToFd*(container: Container, fdin: FileHandle, wait: bool): + EmptyPromise = + return container.iface.redirectToFd(fdin, wait) + +proc readFromFd*(container: Container, fdout: FileHandle, ishtml: bool): + EmptyPromise = + return container.iface.readFromFd(fdout, ishtml) + +proc quit*(container: Container) = + container.triggerEvent(QUIT) + +proc cancel*(container: Container) {.jsfunc.} = + if container.select.open: + container.select.cancel() + else: + container.canceled = true + container.alert("Canceled loading") + +proc findAnchor*(container: Container, anchor: string) = + container.iface.findAnchor(anchor).then(proc(found: bool) = + if found: + container.triggerEvent(ContainerEvent(t: ANCHOR, anchor: anchor)) + else: + container.triggerEvent(NO_ANCHOR)) + +proc readCanceled*(container: Container) = + container.iface.readCanceled().then(proc(repaint: bool) = + if repaint: + container.needslines = true) + +proc readSuccess*(container: Container, s: string) = + container.iface.readSuccess(s).then(proc(res: ReadSuccessResult) = + if res.repaint: + container.needslines = true + if res.open.isSome: + container.triggerEvent(ContainerEvent(t: OPEN, request: res.open.get))) + +proc reshape(container: Container): EmptyPromise {.discardable, jsfunc.} = + return container.iface.render().then(proc(lines: int): auto = + container.setNumLines(lines) + return container.requestLines()) + +proc pipeBuffer*(container, pipeTo: Container) = + container.iface.getSource().then(proc() = + pipeTo.load() #TODO do not load if pipeTo is killed first? + ) + +proc onclick(container: Container, res: ClickResult) + +proc displaySelect(container: Container, selectResult: SelectResult) = + let submitSelect = proc(selected: seq[int]) = + container.iface.select(selected).then(proc(res: ClickResult) = + container.onclick(res)) + container.select.initSelect(selectResult, container.acursorx, + container.acursory, container.height, submitSelect) + container.triggerEvent(UPDATE) + +proc onclick(container: Container, res: ClickResult) = + if res.repaint: + container.needslines = true + if res.open.isSome: + container.triggerEvent(ContainerEvent(t: OPEN, request: res.open.get)) + if res.select.isSome: + container.displaySelect(res.select.get) + if res.readline.isSome: + let rl = res.readline.get + let event = if rl.area: + ContainerEvent( + t: READ_AREA, + tvalue: rl.value + ) + else: + ContainerEvent( + t: READ_LINE, + prompt: rl.prompt, + value: rl.value, + password: rl.hide + ) + container.triggerEvent(event) + +proc click(container: Container) {.jsfunc.} = + if container.select.open: + container.select.click() + else: + container.iface.click(container.cursorx, container.cursory) + .then(proc(res: ClickResult) = container.onclick(res)) + +proc windowChange*(container: Container, attrs: WindowAttributes) = + if attrs.width != container.width or attrs.height - 1 != container.height: + container.width = attrs.width + container.height = attrs.height - 1 + container.iface.windowChange(attrs).then(proc(): auto = + container.needslines = true + return container.iface.render() + ).then(proc(lines: int) = + if lines != container.numLines: + container.setNumLines(lines, true) + container.needslines = true) + +proc peek(container: Container) {.jsfunc.} = + container.alert($container.source.location) + +proc clearHover*(container: Container) = + container.lastpeek = low(HoverType) + +proc peekCursor(container: Container) {.jsfunc.} = + var p = container.lastpeek + while true: + if container.hovertext[p] != "": + container.alert($p & ": " & container.hovertext[p]) + break + if p < high(HoverType): + inc p + else: + p = low(HoverType) + if p == container.lastpeek: break + if container.lastpeek < high(HoverType): + inc container.lastpeek + else: + container.lastpeek = low(HoverType) + +proc handleCommand(container: Container) = + var packetid, len: int + container.iface.stream.sread(len) + container.iface.stream.sread(packetid) + container.iface.resolve(packetid, len - slen(packetid)) + +proc setStream*(container: Container, stream: Stream) = + container.iface = newBufferInterface(stream) + if container.source.t == LOAD_PIPE: + container.iface.passFd(container.source.fd).then(proc() = + discard close(container.source.fd)) + stream.flush() + container.load() + +proc onreadline(container: Container, w: Slice[int], handle: (proc(line: SimpleFlexibleLine)), res: GetLinesResult) = + for line in res.lines: + handle(line) + if res.numLines > w.b + 1: + var w = w + w.a += 24 + w.b += 24 + container.iface.getLines(w).then(proc(res: GetLinesResult) = + container.onreadline(w, handle, res)) + else: + container.setNumLines(res.numLines, true) + +# Synchronously read all lines in the buffer. +proc readLines*(container: Container, handle: (proc(line: SimpleFlexibleLine))) = + if container.code == 0: + # load succeded + let w = 0 .. 23 + container.iface.getLines(w).then(proc(res: GetLinesResult) = + container.onreadline(w, handle, res)) + while container.iface.hasPromises: + # fulfill all promises + container.handleCommand() + +proc drawLines*(container: Container, display: var FixedGrid, + hlcolor: CellColor) = + var r: Rune + var by = 0 + let endy = min(container.fromy + display.height, container.numLines) + for line in container.ilines(container.fromy ..< endy): + var w = 0 # width of the row so far + var i = 0 # byte in line.str + # Skip cells till fromx. + while w < container.fromx and i < line.str.len: + fastRuneAt(line.str, i, r) + w += r.twidth(w) + let dls = by * display.width # starting position of row in display + # Fill in the gap in case we skipped more cells than fromx mandates (i.e. + # we encountered a double-width character.) + var k = 0 + if w > container.fromx: + while k < w - container.fromx: + display[dls + k].str &= ' ' + inc k + var cf = line.findFormat(w) + var nf = line.findNextFormat(w) + let startw = w # save this for later + # Now fill in the visible part of the row. + while i < line.str.len: + let pw = w + fastRuneAt(line.str, i, r) + let rw = r.twidth(w) + w += rw + if w > container.fromx + display.width: + break # die on exceeding the width limit + if nf.pos != -1 and nf.pos <= pw: + cf = nf + nf = line.findNextFormat(pw) + if cf.pos != -1: + display[dls + k].format = cf.format + if r == Rune('\t'): + # Needs to be replaced with spaces, otherwise bgcolor isn't displayed. + let tk = k + rw + while k < tk: + display[dls + k].str &= ' ' + inc k + else: + display[dls + k].str &= r + k += rw + # Finally, override cell formatting for highlighted cells. + let hls = container.findHighlights(container.fromy + by) + let aw = container.width - (startw - container.fromx) # actual width + for hl in hls: + let area = hl.colorArea(container.fromy + by, startw .. startw + aw) + for i in area: + var hlformat = display[dls + i - startw].format + hlformat.bgcolor = hlcolor + display[dls + i - startw].format = hlformat + inc by + +proc handleEvent*(container: Container) = + container.handleCommand() + if container.needslines: + container.requestLines() + container.needslines = false + +proc addContainerModule*(ctx: JSContext) = + ctx.registerType(Container, name = "Buffer") diff --git a/src/local/pager.nim b/src/local/pager.nim new file mode 100644 index 00000000..8bb1172f --- /dev/null +++ b/src/local/pager.nim @@ -0,0 +1,1189 @@ +import deques +import net +import options +import os +import osproc +import streams +import tables +import unicode + +when defined(posix): + import posix + +import buffer/cell +import config/config +import config/mailcap +import config/mimetypes +import display/term +import extern/editor +import extern/runproc +import io/connecterror +import io/lineedit +import io/loader +import io/promise +import io/request +import io/tempfile +import io/window +import ips/forkserver +import ips/socketstream +import js/dict +import js/javascript +import js/regex +import js/tojs +import local/container +import local/select +import types/buffersource +import types/color +import types/cookie +import types/url +import utils/opt +import utils/twtstr + +import chakasu/charset + +type + LineMode* = enum + NO_LINEMODE, LOCATION, USERNAME, PASSWORD, COMMAND, BUFFER, SEARCH_F, + SEARCH_B, ISEARCH_F, ISEARCH_B, GOTO_LINE + + Pager* = ref object + alerton: bool + alerts: seq[string] + askcursor: int + askpromise*: Promise[bool] + askprompt: string + commandMode* {.jsget.}: bool + config: Config + container*: Container + cookiejars: Table[string, CookieJar] + display: FixedGrid + forkserver: ForkServer + iregex: Result[Regex, string] + isearchpromise: EmptyPromise + lineedit*: Option[LineEdit] + linehist: array[LineMode, LineHistory] + linemode*: LineMode + mailcap: Mailcap + mainproc: Pid + mimeTypes: MimeTypes + numload*: int + omnirules: seq[OmniRule] + procmap*: Table[Pid, Container] + proxy: URL + redraw*: bool + regex: Opt[Regex] + reverseSearch: bool + scommand*: string + siteconf: seq[SiteConfig] + statusgrid*: FixedGrid + term*: Terminal + tty: File + unreg*: seq[(Pid, SocketStream)] + username: string + +jsDestructor(Pager) + +func attrs(pager: Pager): WindowAttributes = pager.term.attrs + +func getRoot(container: Container): Container = + var c = container + while c.parent != nil: c = c.parent + return c + +iterator all_children(parent: Container): Container {.inline.} = + var stack = newSeqOfCap[Container](parent.children.len) + for i in countdown(parent.children.high, 0): + stack.add(parent.children[i]) + while stack.len > 0: + let c = stack.pop() + yield c + for i in countdown(c.children.high, 0): + stack.add(c.children[i]) + +iterator containers*(pager: Pager): Container {.inline.} = + if pager.container != nil: + let root = getRoot(pager.container) + yield root + for c in root.all_children: + yield c + +proc setContainer*(pager: Pager, c: Container) {.jsfunc.} = + pager.container = c + pager.redraw = true + if c != nil: + pager.term.setTitle(c.getTitle()) + +proc hasprop(ctx: JSContext, pager: Pager, s: string): bool {.jshasprop.} = + if pager.container != nil: + let cval = toJS(ctx, pager.container) + let val = JS_GetPropertyStr(ctx, cval, s) + if val != JS_UNDEFINED: + result = true + JS_FreeValue(ctx, val) + +proc reflect(ctx: JSContext, this_val: JSValue, argc: cint, argv: ptr JSValue, + magic: cint, func_data: ptr JSValue): JSValue {.cdecl.} = + let fun = cast[ptr JSValue](cast[int](func_data) + sizeof(JSValue))[] + return JS_Call(ctx, fun, func_data[], argc, argv) + +proc getter(ctx: JSContext, pager: Pager, s: string): Option[JSValue] + {.jsgetprop.} = + if pager.container != nil: + let cval = toJS(ctx, pager.container) + let val = JS_GetPropertyStr(ctx, cval, s) + if val != JS_UNDEFINED: + if JS_IsFunction(ctx, val): + var func_data = @[cval, val] + let fun = JS_NewCFunctionData(ctx, reflect, 1, 0, 2, addr func_data[0]) + return some(fun) + return some(val) + +proc searchNext(pager: Pager) {.jsfunc.} = + if pager.regex.isSome: + let wrap = pager.config.search.wrap + if not pager.reverseSearch: + pager.container.cursorNextMatch(pager.regex.get, wrap, true) + else: + pager.container.cursorPrevMatch(pager.regex.get, wrap, true) + +proc searchPrev(pager: Pager) {.jsfunc.} = + if pager.regex.isSome: + let wrap = pager.config.search.wrap + if not pager.reverseSearch: + pager.container.cursorPrevMatch(pager.regex.get, wrap, true) + else: + pager.container.cursorNextMatch(pager.regex.get, wrap, true) + +proc getLineHist(pager: Pager, mode: LineMode): LineHistory = + if pager.linehist[mode] == nil: + pager.linehist[mode] = newLineHistory() + return pager.linehist[mode] + +proc setLineEdit(pager: Pager, prompt: string, mode: LineMode, current = "", hide = false) = + pager.lineedit = some(readLine(prompt, pager.attrs.width, current = current, term = pager.term, hide = hide, hist = pager.getLineHist(mode))) + pager.linemode = mode + +proc clearLineEdit(pager: Pager) = + pager.lineedit = none(LineEdit) + +proc searchForward(pager: Pager) {.jsfunc.} = + pager.setLineEdit("/", SEARCH_F) + +proc searchBackward(pager: Pager) {.jsfunc.} = + pager.setLineEdit("?", SEARCH_B) + +proc isearchForward(pager: Pager) {.jsfunc.} = + pager.container.pushCursorPos() + pager.isearchpromise = newResolvedPromise() + pager.setLineEdit("/", ISEARCH_F) + +proc isearchBackward(pager: Pager) {.jsfunc.} = + pager.container.pushCursorPos() + pager.isearchpromise = newResolvedPromise() + pager.setLineEdit("?", ISEARCH_B) + +proc gotoLine[T: string|int](pager: Pager, s: T = "") {.jsfunc.} = + when s is string: + if s == "": + pager.setLineEdit("Goto line: ", GOTO_LINE) + return + pager.container.gotoLine(s) + +proc alert*(pager: Pager, msg: string) + +proc newPager*(config: Config, attrs: WindowAttributes, + forkserver: ForkServer, mainproc: Pid, ctx: JSContext): Pager = + let pager = Pager( + config: config, + display: newFixedGrid(attrs.width, attrs.height - 1), + forkserver: forkserver, + mainproc: mainproc, + omnirules: config.getOmniRules(ctx), + proxy: config.getProxy(), + siteconf: config.getSiteConfig(ctx), + statusgrid: newFixedGrid(attrs.width), + term: newTerminal(stdout, config, attrs), + mimeTypes: config.getMimeTypes() + ) + let (mcap, errs) = config.getMailcap() + pager.mailcap = mcap + for err in errs: + pager.alert("Error reading mailcap: " & err) + return pager + +proc launchPager*(pager: Pager, tty: File) = + pager.tty = tty + pager.term.start(tty) + +proc dumpAlerts*(pager: Pager) = + for msg in pager.alerts: + stderr.write("cha: " & msg & '\n') + +proc quit*(pager: Pager, code = 0) = + pager.term.quit() + pager.dumpAlerts() + +proc clearDisplay(pager: Pager) = + pager.display = newFixedGrid(pager.display.width, pager.display.height) + +proc buffer(pager: Pager): Container {.jsfget, inline.} = pager.container + +proc refreshDisplay(pager: Pager, container = pager.container) = + pager.clearDisplay() + container.drawLines(pager.display, + cellColor(pager.config.display.highlight_color)) + +# Note: this function doesn't work if start < i of last written char +proc writeStatusMessage(pager: Pager, str: string, + format: Format = newFormat(), start = 0, + maxwidth = -1, clip = '$'): int {.discardable.} = + var maxwidth = maxwidth + if maxwidth == -1: + maxwidth = pager.statusgrid.len + var i = start + let e = min(start + maxwidth, pager.statusgrid.width) + if i >= e: + return i + for r in str.runes: + let pi = i + i += r.twidth(i) + if i >= e: + if i >= pager.statusgrid.width: + i = pi + pager.statusgrid[i].format = format + pager.statusgrid[i].str = $clip + inc i + break + if r.isControlChar(): + pager.statusgrid[pi].str = "^" & getControlLetter(char(r)) + else: + pager.statusgrid[pi].str = $r + pager.statusgrid[pi].format = format + result = i + var def = newFormat() + while i < e: + pager.statusgrid[i].str = "" + pager.statusgrid[i].format = def + inc i + +# Note: should only be called directly after user interaction. +proc refreshStatusMsg*(pager: Pager) = + let container = pager.container + if container == nil: return + if pager.tty == nil: return + if pager.askpromise != nil: return + if container.loadinfo != "": + pager.alerton = false + pager.writeStatusMessage(container.loadinfo) + elif pager.alerts.len > 0: + pager.alerton = true + pager.writeStatusMessage(pager.alerts[0]) + pager.alerts.delete(0) + else: + var format = newFormat() + format.reverse = true + pager.alerton = false + container.clearHover() + var msg = $(container.cursory + 1) & "/" & $container.numLines & " (" & + $container.atPercentOf() & "%)" + let mw = pager.writeStatusMessage(msg, format) + let title = " <" & container.getTitle() & ">" + let hover = container.getHoverText() + if hover.len == 0: + pager.writeStatusMessage(title, format, mw) + else: + let hover2 = " " & hover + let maxwidth = pager.statusgrid.width - hover2.width() - mw + let tw = pager.writeStatusMessage(title, format, mw, maxwidth, '>') + pager.writeStatusMessage(hover2, format, tw) + +# Call refreshStatusMsg if no alert is being displayed on the screen. +proc showAlerts*(pager: Pager) = + if not pager.alerton: + pager.refreshStatusMsg() + +proc drawBuffer*(pager: Pager, container: Container, ostream: Stream) = + var format = newFormat() + container.readLines(proc(line: SimpleFlexibleLine) = + if line.formats.len == 0: + ostream.write(line.str & "\n") + else: + var x = 0 + var w = 0 + var i = 0 + var s = "" + for f in line.formats: + var outstr = "" + while x < f.pos: + var r: Rune + fastRuneAt(line.str, i, r) + outstr &= r + x += r.width() + s &= pager.term.processOutputString(outstr, w) + s &= pager.term.processFormat(format, f.format) + if i < line.str.len: + s &= pager.term.processOutputString(line.str.substr(i), w) + s &= pager.term.processFormat(format, newFormat()) & "\n" + ostream.write(s)) + ostream.flush() + +proc redraw(pager: Pager) {.jsfunc.} = + pager.redraw = true + pager.term.clearCanvas() + +proc draw*(pager: Pager) = + let container = pager.container + if container == nil: return + pager.term.hideCursor() + if pager.redraw: + pager.refreshDisplay() + pager.term.writeGrid(pager.display) + if container.select.open and container.select.redraw: + container.select.drawSelect(pager.display) + pager.term.writeGrid(pager.display) + if pager.askpromise != nil: + discard + elif pager.lineedit.isSome: + if pager.lineedit.get.isnew: + #TODO hack + # make term notice that it must redraw when status is restored + let x = newFixedGrid(pager.attrs.width) + pager.term.writeGrid(x, 0, pager.attrs.height - 1) + else: + pager.term.writeGrid(pager.statusgrid, 0, pager.attrs.height - 1) + pager.term.outputGrid() + if pager.askpromise != nil: + pager.term.setCursor(pager.askcursor, pager.attrs.height - 1) + elif pager.lineedit.isSome: + if pager.lineedit.get.isnew: + #TODO hack + pager.term.setCursor(0, pager.attrs.height - 1) + pager.lineedit.get.drawPrompt() + pager.term.setCursor(pager.lineedit.get.getCursorX(), pager.attrs.height - 1) + pager.lineedit.get.fullRedraw() + pager.lineedit.get.isnew = false + pager.term.setCursor(pager.lineedit.get.getCursorX(), pager.attrs.height - 1) + elif container.select.open: + pager.term.setCursor(container.select.getCursorX(), + container.select.getCursorY()) + else: + pager.term.setCursor(pager.container.acursorx, pager.container.acursory) + pager.term.showCursor() + pager.term.flush() + pager.redraw = false + +proc writeAskPrompt(pager: Pager) = + let yn = " (y/n)" + let maxwidth = pager.statusgrid.width - yn.len + let i = pager.writeStatusMessage(pager.askprompt, maxwidth = maxwidth) + pager.askcursor = pager.writeStatusMessage(yn, start = i) + pager.term.writeGrid(pager.statusgrid, 0, pager.attrs.height - 1) + +proc ask(pager: Pager, prompt: string): Promise[bool] {.jsfunc.} = + pager.askprompt = prompt + pager.writeAskPrompt() + pager.askpromise = Promise[bool]() + return pager.askpromise + +proc fulfillAsk*(pager: Pager, y: bool) = + pager.askpromise.resolve(y) + pager.askpromise = nil + pager.askprompt = "" + +proc registerContainer*(pager: Pager, container: Container) = + pager.procmap[container.process] = container + +proc addContainer*(pager: Pager, container: Container) = + container.parent = pager.container + if pager.container != nil: + pager.container.children.insert(container, 0) + pager.registerContainer(container) + pager.setContainer(container) + +proc newBuffer(pager: Pager, bufferConfig: BufferConfig, source: BufferSource, + title = "", redirectdepth = 0): Container = + return newBuffer( + pager.forkserver, + pager.mainproc, + bufferConfig, + source, + title, + redirectdepth + ) + +proc dupeBuffer(pager: Pager, container: Container, location: URL, + contentType = ""): Container = + let contentType = if contentType != "": + some(contentType) + else: + container.contenttype + let location = if location != nil: + location + else: + container.source.location + let source = BufferSource( + t: CLONE, + location: location, + contenttype: contentType, + clonepid: container.process, + ) + let pipeTo = pager.newBuffer(container.config, source, container.title) + container.pipeBuffer(pipeTo) + return pipeTo + +proc dupeBuffer(pager: Pager, location: URL = nil) {.jsfunc.} = + pager.addContainer(pager.dupeBuffer(pager.container, location)) + +# The prevBuffer and nextBuffer procedures emulate w3m's PREV and NEXT +# commands by traversing the container tree in a depth-first order. +proc prevBuffer(pager: Pager): bool {.jsfunc.} = + if pager.container == nil: + return false + if pager.container.parent == nil: + return false + let n = pager.container.parent.children.find(pager.container) + assert n != -1, "Container not a child of its parent" + if n > 0: + var container = pager.container.parent.children[n - 1] + while container.children.len > 0: + container = container.children[^1] + pager.setContainer(container) + else: + pager.setContainer(pager.container.parent) + return true + +proc nextBuffer(pager: Pager): bool {.jsfunc.} = + if pager.container == nil: + return false + if pager.container.children.len > 0: + pager.setContainer(pager.container.children[0]) + return true + var container = pager.container + while container.parent != nil: + let n = container.parent.children.find(container) + assert n != -1, "Container not a child of its parent" + if n < container.parent.children.high: + pager.setContainer(container.parent.children[n + 1]) + return true + container = container.parent + return false + +proc parentBuffer(pager: Pager): bool {.jsfunc.} = + if pager.container == nil: + return false + if pager.container.parent == nil: + return false + pager.setContainer(pager.container.parent) + return true + +proc prevSiblingBuffer(pager: Pager): bool {.jsfunc.} = + if pager.container == nil: + return false + if pager.container.parent == nil: + return false + var n = pager.container.parent.children.find(pager.container) + assert n != -1, "Container not a child of its parent" + if n == 0: + n = pager.container.parent.children.len + pager.setContainer(pager.container.parent.children[n - 1]) + return true + +proc nextSiblingBuffer(pager: Pager): bool {.jsfunc.} = + if pager.container == nil: + return false + if pager.container.parent == nil: + return false + var n = pager.container.parent.children.find(pager.container) + assert n != -1, "Container not a child of its parent" + if n == pager.container.parent.children.high: + n = -1 + pager.setContainer(pager.container.parent.children[n + 1]) + return true + +proc alert*(pager: Pager, msg: string) {.jsfunc.} = + pager.alerts.add(msg) + +proc deleteContainer(pager: Pager, container: Container) = + container.cancel() + if container.sourcepair != nil: + container.sourcepair.sourcepair = nil + container.sourcepair = nil + if container.parent != nil: + let parent = container.parent + let n = parent.children.find(container) + assert n != -1, "Container not a child of its parent" + for i in countdown(container.children.high, 0): + let child = container.children[i] + child.parent = container.parent + parent.children.insert(child, n + 1) + parent.children.delete(n) + if container == pager.container: + if n == 0: + pager.setContainer(parent) + else: + pager.setContainer(parent.children[n - 1]) + elif container.children.len > 0: + let parent = container.children[0] + parent.parent = nil + for i in 1..container.children.high: + container.children[i].parent = parent + parent.children.add(container.children[i]) + if container == pager.container: + pager.setContainer(parent) + else: + for child in container.children: + child.parent = nil + if container == pager.container: + pager.setContainer(nil) + container.parent = nil + container.children.setLen(0) + pager.unreg.add((container.process, SocketStream(container.iface.stream))) + pager.forkserver.removeChild(container.process) + +proc discardBuffer(pager: Pager, container = none(Container)) {.jsfunc.} = + let c = container.get(pager.container) + if c == nil or c.parent == nil and c.children.len == 0: + pager.alert("Cannot discard last buffer!") + else: + pager.deleteContainer(c) + +proc discardTree(pager: Pager, container = none(Container)) {.jsfunc.} = + let container = container.get(pager.container) + if container != nil: + for c in container.all_children: + pager.deleteContainer(c) + else: + pager.alert("Buffer has no children!") + +proc toggleSource(pager: Pager) {.jsfunc.} = + if pager.container.sourcepair != nil: + pager.setContainer(pager.container.sourcepair) + else: + let contenttype = if pager.container.contentType.get("") == "text/html": + "text/plain" + else: + "text/html" + let container = pager.dupeBuffer(pager.container, nil, contenttype) + container.sourcepair = pager.container + pager.container.sourcepair = container + pager.addContainer(container) + +proc windowChange*(pager: Pager, attrs: WindowAttributes) = + pager.term.windowChange(attrs) + pager.display = newFixedGrid(attrs.width, attrs.height - 1) + pager.statusgrid = newFixedGrid(attrs.width) + for container in pager.containers: + container.windowChange(attrs) + if pager.askprompt != "": + pager.writeAskPrompt() + pager.showAlerts() + +# Apply siteconf settings to a request. +# Note that this may modify the URL passed. +proc applySiteconf(pager: Pager, url: var URL): BufferConfig = + let host = url.host + var referer_from: bool + var cookiejar: CookieJar + var headers = pager.config.getDefaultHeaders() + var scripting: bool + var images: bool + var charsets = pager.config.encoding.document_charset + var userstyle = pager.config.css.stylesheet + var proxy = pager.proxy + let mimeTypes = pager.mimeTypes + for sc in pager.siteconf: + if sc.url.isSome and not sc.url.get.match($url): + continue + elif sc.host.isSome and not sc.host.get.match(host): + continue + if sc.rewrite_url != nil: + let s = sc.rewrite_url(url) + if s.isSome and s.get != nil: + url = s.get + if sc.cookie.isSome: + if sc.cookie.get: + # host/url might have changed by now + let jarid = sc.share_cookiejar.get(url.host) + if jarid notin pager.cookiejars: + pager.cookiejars[jarid] = newCookieJar(url, + sc.third_party_cookie) + cookiejar = pager.cookiejars[jarid] + else: + cookiejar = nil # override + if sc.scripting.isSome: + scripting = sc.scripting.get + if sc.referer_from.isSome: + referer_from = sc.referer_from.get + if sc.document_charset.len > 0: + charsets = sc.document_charset + if sc.images.isSome: + images = sc.images.get + if sc.stylesheet.isSome: + userstyle &= "\n" + userstyle &= sc.stylesheet.get + if sc.proxy.isSome: + proxy = sc.proxy.get + return pager.config.getBufferConfig(url, cookiejar, headers, referer_from, + scripting, charsets, images, userstyle, proxy, mimeTypes) + +# Load request in a new buffer. +proc gotoURL(pager: Pager, request: Request, prevurl = none(URL), + ctype = none(string), cs = CHARSET_UNKNOWN, replace: Container = nil, + redirectdepth = 0, referrer: Container = nil) = + if referrer != nil and referrer.config.referer_from: + request.referer = referrer.source.location + var bufferconfig = pager.applySiteconf(request.url) + if prevurl.isnone or not prevurl.get.equals(request.url, true) or + request.url.hash == "" or request.httpmethod != HTTP_GET: + # Basically, we want to reload the page *only* when + # a) we force a reload (by setting prevurl to none) + # b) or the new URL isn't just the old URL + an anchor + # I think this makes navigation pretty natural, or at least very close to + # what other browsers do. Still, it would be nice if we got some visual + # feedback on what is actually going to happen when typing a URL; TODO. + let source = BufferSource( + t: LOAD_REQUEST, + request: request, + contenttype: ctype, + charset: cs, + location: request.url + ) + if referrer != nil: + bufferconfig.referrerpolicy = referrer.config.referrerpolicy + let container = pager.newBuffer( + bufferconfig, + source, + redirectdepth = redirectdepth + ) + if replace != nil: + container.replace = replace + container.copyCursorPos(container.replace) + pager.addContainer(container) + inc pager.numload + else: + pager.container.findAnchor(request.url.anchor) + +proc omniRewrite(pager: Pager, s: string): string = + for rule in pager.omnirules: + if rule.match.match(s): + let sub = rule.substitute_url(s) + if sub.isSome: + return sub.get + else: + pager.alert("Error in substitution of rule " & rule.match.buf & " for " & s) + return s + +# When the user has passed a partial URL as an argument, they might've meant +# either: +# * file://$PWD/<file> +# * https://<url> +# So we attempt to load both, and see what works. +proc loadURL*(pager: Pager, url: string, ctype = none(string), + cs = CHARSET_UNKNOWN) = + let url0 = pager.omniRewrite(url) + let url = if url[0] == '~': expandPath(url0) else: url0 + let firstparse = parseURL(url) + if firstparse.issome: + let prev = if pager.container != nil: + some(pager.container.source.location) + else: + none(URL) + pager.gotoURL(newRequest(firstparse.get), prev, ctype, cs) + return + var urls: seq[URL] + if pager.config.network.prepend_https and url[0] != '/': + let pageurl = parseURL("https://" & url) + if pageurl.isSome: # attempt to load remote page + urls.add(pageurl.get) + let cdir = parseURL("file://" & percentEncode(getCurrentDir(), LocalPathPercentEncodeSet) & DirSep) + let localurl = percentEncode(url, LocalPathPercentEncodeSet) + let newurl = parseURL(localurl, cdir) + if newurl.isSome: + urls.add(newurl.get) # attempt to load local file + if urls.len == 0: + pager.alert("Invalid URL " & url) + else: + let prevc = pager.container + pager.gotoURL(newRequest(urls.pop()), ctype = ctype, cs = cs) + if pager.container != prevc: + pager.container.retry = urls + +proc readPipe0*(pager: Pager, ctype: Option[string], cs: Charset, + fd: FileHandle, location: Option[URL], title: string): Container = + var location = location.get(newURL("file://-").get) + let bufferconfig = pager.applySiteconf(location) + let source = BufferSource( + t: LOAD_PIPE, + fd: fd, + contenttype: some(ctype.get("text/plain")), + charset: cs, + location: location + ) + return pager.newBuffer(bufferconfig, source, title = title) + +proc readPipe*(pager: Pager, ctype: Option[string], cs: Charset, + fd: FileHandle) = + let container = pager.readPipe0(ctype, cs, fd, none(URL), "*pipe*") + pager.addContainer(container) + +proc command(pager: Pager) {.jsfunc.} = + pager.setLineEdit("COMMAND: ", COMMAND) + +proc commandMode(pager: Pager, val: bool) {.jsfset.} = + pager.commandMode = val + if val: + pager.command() + +proc checkRegex(pager: Pager, regex: Result[Regex, string]): Opt[Regex] = + if regex.isErr: + pager.alert("Invalid regex: " & regex.error) + return err() + return ok(regex.get) + +proc updateReadLineISearch(pager: Pager, linemode: LineMode) = + let lineedit = pager.lineedit.get + pager.isearchpromise = pager.isearchpromise.then(proc(): EmptyPromise = + case lineedit.state + of CANCEL: + pager.iregex.err() + pager.container.popCursorPos() + pager.container.clearSearchHighlights() + pager.redraw = true + pager.isearchpromise = nil + of EDIT: + let x = $lineedit.news + if x != "": pager.iregex = compileSearchRegex(x) + pager.container.popCursorPos(true) + pager.container.pushCursorPos() + if pager.iregex.isSome: + pager.container.hlon = true + let wrap = pager.config.search.wrap + return if linemode == ISEARCH_F: + pager.container.cursorNextMatch(pager.iregex.get, wrap, false) + else: + pager.container.cursorPrevMatch(pager.iregex.get, wrap, false) + of FINISH: + pager.regex = pager.checkRegex(pager.iregex) + pager.reverseSearch = linemode == ISEARCH_B + pager.container.clearSearchHighlights() + pager.container.sendCursorPosition() + pager.redraw = true + pager.isearchpromise = nil + ) + +proc updateReadLine*(pager: Pager) = + let lineedit = pager.lineedit.get + template s: string = $lineedit.news + if pager.linemode in {ISEARCH_F, ISEARCH_B}: + pager.updateReadLineISearch(pager.linemode) + else: + case lineedit.state + of EDIT: return + of FINISH: + case pager.linemode + of LOCATION: pager.loadURL(s) + of USERNAME: + pager.username = s + pager.setLineEdit("Password: ", PASSWORD, hide = true) + of PASSWORD: + let url = newURL(pager.container.source.location) + url.username = pager.username + url.password = s + pager.username = "" + pager.gotoURL(newRequest(url), some(pager.container.source.location), replace = pager.container, referrer = pager.container) + of COMMAND: + pager.scommand = s + if pager.commandMode: + pager.command() + of BUFFER: pager.container.readSuccess(s) + of SEARCH_F, SEARCH_B: + let x = s + if x != "": + pager.regex = pager.checkRegex(compileSearchRegex(x)) + pager.reverseSearch = pager.linemode == SEARCH_B + pager.searchNext() + of GOTO_LINE: + pager.container.gotoLine(s) + else: discard + of CANCEL: + case pager.linemode + of USERNAME: pager.discardBuffer() + of PASSWORD: + pager.username = "" + pager.discardBuffer() + of BUFFER: pager.container.readCanceled() + of COMMAND: pager.commandMode = false + else: discard + if lineedit.state in {CANCEL, FINISH}: + if pager.lineedit.get == lineedit: + pager.clearLineEdit() + +# Open a URL prompt and visit the specified URL. +proc load(pager: Pager, s = "") {.jsfunc.} = + if s.len > 0 and s[^1] == '\n': + pager.loadURL(s[0..^2]) + else: + var url = s + if url == "": + url = pager.container.source.location.serialize() + pager.setLineEdit("URL: ", LOCATION, url) + +# Reload the page in a new buffer, then kill the previous buffer. +proc reload(pager: Pager) {.jsfunc.} = + pager.gotoURL(newRequest(pager.container.source.location), none(URL), + pager.container.contenttype, replace = pager.container) + +proc setEnvVars(pager: Pager) {.jsfunc.} = + try: + putEnv("CHA_URL", $pager.container.location) + putEnv("CHA_CHARSET", $pager.container.charset) + except OSError: + pager.alert("Warning: failed to set some environment variables") + +#TODO use default values instead... +type ExternDict = object of JSDict + setenv: Opt[bool] + suspend: Opt[bool] + wait: bool + +#TODO this could be handled much better. +# * suspend, setenv, wait as dict flags +# * retval as int? +proc extern(pager: Pager, cmd: string, t = ExternDict()): bool {.jsfunc.} = + if t.setenv.get(true): + pager.setEnvVars() + if t.suspend.get(true): + return runProcess(pager.term, cmd, t.wait) + else: + return runProcess(cmd) + +proc authorize(pager: Pager) = + pager.setLineEdit("Username: ", USERNAME) + +# Pipe input into the mailcap command, then read its output into a buffer. +# needsterminal is ignored. +proc runMailcapReadPipe(pager: Pager, container: Container, + entry: MailcapEntry, cmd: string): (EmptyPromise, bool) = + var pipefd_in: array[2, cint] + if pipe(pipefd_in) == -1: + raise newException(Defect, "Failed to open pipe.") + var pipefd_out: array[2, cint] + if pipe(pipefd_out) == -1: + raise newException(Defect, "Failed to open pipe.") + let pid = fork() + if pid == -1: + return (nil, false) + elif pid == 0: + # child process + discard close(pipefd_in[1]) + discard close(pipefd_out[0]) + stdout.flushFile() + discard dup2(pipefd_in[0], stdin.getFileHandle()) + discard dup2(pipefd_out[1], stdout.getFileHandle()) + let devnull = open("/dev/null", O_WRONLY) + discard dup2(devnull, stderr.getFileHandle()) + discard close(devnull) + discard close(pipefd_in[0]) + discard close(pipefd_out[1]) + discard execCmd(cmd) + discard close(stdin.getFileHandle()) + discard close(stdout.getFileHandle()) + quit(0) + # parent + discard close(pipefd_in[0]) + discard close(pipefd_out[1]) + let fdin = pipefd_in[1] + let fdout = pipefd_out[0] + let p = container.redirectToFd(fdin, wait = false) + let p2 = p.then(proc(): auto = + discard close(fdin) + let ishtml = HTMLOUTPUT in entry.flags + if ishtml: + #TODO this is a hack for dupe buffer and should be reconsidered. + container.source.contenttype = some("text/html") + return container.readFromFd(fdout, ishtml) + ).then(proc() = + discard close(fdout) + ) + return (p2, true) + +# Pipe input into the mailcap command, and discard its output. +# If needsterminal, leave stderr and stdout open and wait for the process. +proc runMailcapWritePipe(pager: Pager, container: Container, + entry: MailcapEntry, cmd: string): (EmptyPromise, bool) = + let needsterminal = NEEDSTERMINAL in entry.flags + var pipefd: array[2, cint] + if pipe(pipefd) == -1: + raise newException(Defect, "Failed to open pipe.") + if needsterminal: + pager.term.quit() + let pid = fork() + if pid == -1: + return (nil, false) + elif pid == 0: + # child process + discard close(pipefd[1]) + discard dup2(pipefd[0], stdin.getFileHandle()) + if not needsterminal: + let devnull = open("/dev/null", O_WRONLY) + discard dup2(devnull, stdout.getFileHandle()) + discard dup2(devnull, stderr.getFileHandle()) + discard close(devnull) + discard close(pipefd[0]) + discard execCmd(cmd) + discard close(stdin.getFileHandle()) + quit(0) + else: + # parent + discard close(pipefd[0]) + let fd = pipefd[1] + let p = container.redirectToFd(fd, wait = false) + discard close(fd) + if needsterminal: + var x: cint + discard waitpid(pid, x, 0) + pager.term.restart() + return (p, false) + +# Save input in a file, run the command, and redirect its output to a +# new buffer. +# needsterminal is ignored. +proc runMailcapReadFile(pager: Pager, container: Container, + entry: MailcapEntry, cmd, outpath: string): (EmptyPromise, bool) = + let fd = open(outpath, O_WRONLY or O_CREAT, 0o666) + if fd == -1: + return (nil, false) + let p = container.redirectToFd(fd, wait = true).then(proc(): auto = + var pipefd: array[2, cint] # redirect stdout here + if pipe(pipefd) == -1: + raise newException(Defect, "Failed to open pipe.") + let pid = fork() + if pid == 0: + # child process + discard close(pipefd[0]) + discard dup2(pipefd[1], stdout.getFileHandle()) + discard close(pipefd[1]) + let devnull = open("/dev/null", O_WRONLY) + discard dup2(devnull, stderr.getFileHandle()) + discard close(devnull) + discard execCmd(cmd) + discard tryRemoveFile(outpath) + quit(0) + # parent + discard close(pipefd[1]) + let fdout = pipefd[0] + let ishtml = HTMLOUTPUT in entry.flags + if ishtml: + #TODO this is a hack for dupe buffer and should be reconsidered. + container.source.contenttype = some("text/html") + return container.readFromFd(fdout, ishtml).then(proc() = + discard close(fdout) + ) + ) + return (p, true) + +# Save input in a file, run the command, and discard its output. +# If needsterminal, leave stderr and stdout open and wait for the process. +proc runMailcapWriteFile(pager: Pager, container: Container, + entry: MailcapEntry, cmd, outpath: string): (EmptyPromise, bool) = + let needsterminal = NEEDSTERMINAL in entry.flags + let fd = open(outpath, O_WRONLY or O_CREAT, 0o666) + if fd == -1: + return (nil, false) + let p = container.redirectToFd(fd, wait = true).then(proc() = + if needsterminal: + pager.term.quit() + discard execCmd(cmd) + discard tryRemoveFile(outpath) + pager.term.restart() + else: + # don't block + let pid = fork() + if pid == 0: + # child process + let devnull = open("/dev/null", O_WRONLY) + discard dup2(devnull, stdin.getFileHandle()) + discard dup2(devnull, stdout.getFileHandle()) + discard dup2(devnull, stderr.getFileHandle()) + discard close(devnull) + discard execCmd(cmd) + discard tryRemoveFile(outpath) + quit(0) + ) + return (p, false) + +# Search for a mailcap entry, and if found, execute the specified command +# and pipeline the input and output appropriately. +# There is four possible outcomes: +# * pipe stdin, discard stdout +# * pipe stdin, read stdout +# * write to file, run, discard stdout +# * write to file, run, read stdout +# If needsterminal is specified, and stdout is not being read, then the +# pager is suspended until the command exits. +#TODO add support for edit/compose, better error handling (use Promise[bool] +# instead of tuple[EmptyPromise, bool]) +proc checkMailcap(pager: Pager, container: Container): (EmptyPromise, bool) = + if container.contenttype.isNone: + return (nil, true) + if container.source.t == CLONE: + return (nil, true) # clone cannot use mailcap + let contentType = container.contenttype.get + if contentType == "text/html": + # We support HTML natively, so it would make little sense to execute + # mailcap filters for it. + return (nil, true) + elif contentType == "text/plain": + # This could potentially be useful. Unfortunately, many mailcaps include + # a text/plain entry with less by default, so it's probably better to + # ignore this. + return (nil, true) + #TODO callback for outpath or something + let url = container.location + let cs = container.source.charset + let entry = pager.mailcap.getMailcapEntry(contentType, "", url, cs) + if entry != nil: + let tmpdir = pager.config.external.tmpdir + let ext = container.location.pathname.afterLast('.') + let tempfile = getTempfile(tmpdir, ext) + let outpath = if entry.nametemplate != "": + unquoteCommand(entry.nametemplate, contentType, tempfile, url, cs) + else: + tempfile + var canpipe = true + let cmd = unquoteCommand(entry.cmd, contentType, outpath, url, cs, canpipe) + if {COPIOUSOUTPUT, HTMLOUTPUT} * entry.flags == {}: + # no output. + if canpipe: + return pager.runMailcapWritePipe(container, entry[], cmd) + else: + return pager.runMailcapWriteFile(container, entry[], cmd, outpath) + else: + if canpipe: + return pager.runMailcapReadPipe(container, entry[], cmd) + else: + return pager.runMailcapReadFile(container, entry[], cmd, outpath) + return (nil, true) + +proc handleEvent0(pager: Pager, container: Container, event: ContainerEvent): bool = + case event.t + of FAIL: + dec pager.numload + pager.deleteContainer(container) + if container.retry.len > 0: + pager.gotoURL(newRequest(container.retry.pop()), ctype = container.contenttype) + else: + let errorMessage = getLoaderErrorMessage(container.code) + pager.alert("Can't load " & $container.source.location & " (" & + errorMessage & ")") + return false + of SUCCESS: + if container.replace != nil: + let n = container.replace.children.find(container) + if n != -1: + container.replace.children.delete(n) + container.parent = nil + let n2 = container.children.find(container.replace) + if n2 != -1: + container.children.delete(n2) + container.replace.parent = nil + container.children.add(container.replace.children) + for child in container.children: + child.parent = container + container.replace.children.setLen(0) + if container.replace.parent != nil: + container.parent = container.replace.parent + let n = container.replace.parent.children.find(container.replace) + assert n != -1, "Container not a child of its parent" + container.parent.children[n] = container + container.replace.parent = nil + if pager.container == container.replace: + pager.setContainer(container) + pager.deleteContainer(container.replace) + container.replace = nil + of LOADED: + dec pager.numload + of NEEDS_AUTH: + if pager.container == container: + pager.authorize() + of REDIRECT: + if container.redirectdepth < pager.config.network.max_redirect: + pager.alert("Redirecting to " & $event.request.url) + pager.gotoURL(event.request, some(container.source.location), + replace = container, redirectdepth = container.redirectdepth + 1, + referrer = pager.container) + else: + pager.alert("Error: maximum redirection depth reached") + pager.deleteContainer(container) + return false + of ANCHOR: + var url2 = newURL(container.source.location) + url2.setHash(event.anchor) + pager.addContainer(pager.dupeBuffer(container, url2)) + of NO_ANCHOR: + pager.alert("Couldn't find anchor " & event.anchor) + of UPDATE: + if container == pager.container: + pager.redraw = true + if event.force: pager.term.clearCanvas() + of READ_LINE: + if container == pager.container: + pager.setLineEdit("(BUFFER) " & event.prompt, BUFFER, event.value, hide = event.password) + of READ_AREA: + if container == pager.container: + var s = event.tvalue + if openInEditor(pager.term, pager.config, s): + pager.container.readSuccess(s) + else: + pager.container.readCanceled() + pager.redraw = true + of OPEN: + if pager.container == nil or not pager.container.isHoverURL(event.request.url): + pager.ask("Open pop-up? " & $event.request.url).then(proc(x: bool) = + if x: + pager.gotoURL(event.request, some(container.source.location), referrer = pager.container)) + else: + pager.gotoURL(event.request, some(container.source.location), referrer = pager.container) + of INVALID_COMMAND: discard + of STATUS: + if pager.container == container: + pager.showAlerts() + of TITLE: + if pager.container == container: + pager.showAlerts() + pager.term.setTitle(container.getTitle()) + of ALERT: + if pager.container == container: + pager.alert(event.msg) + of CHECK_MAILCAP: + var (cm, connect) = pager.checkMailcap(container) + if cm == nil: + cm = container.connect2() + if connect: + cm.then(proc() = + container.startload()) + else: + cm.then(proc(): auto = + container.quit()) + of QUIT: + dec pager.numload + pager.deleteContainer(container) + return false + of NO_EVENT: discard + return true + +proc handleEvents*(pager: Pager, container: Container) = + while container.events.len > 0: + let event = container.events.popFirst() + if not pager.handleEvent0(container, event): + break + +proc handleEvent*(pager: Pager, container: Container) = + try: + container.handleEvent() + pager.handleEvents(container) + except IOError: + discard + +proc addPagerModule*(ctx: JSContext) = + ctx.registerType(Pager) diff --git a/src/local/select.nim b/src/local/select.nim new file mode 100644 index 00000000..f7afa4d9 --- /dev/null +++ b/src/local/select.nim @@ -0,0 +1,306 @@ +import unicode + +import buffer/buffer +import buffer/cell +import js/regex +import utils/twtstr + +type + SubmitSelect* = proc(selected: seq[int]) + CloseSelect* = proc() + + Select* = object + open*: bool + options: seq[string] + multiple: bool + # old selection + oselected*: seq[int] + # new selection + selected*: seq[int] + # cursor distance from y + cursor: int + # widest option + maxw: int + # maximum height on screen (yes the naming is dumb) + maxh: int + # first index to display + si: int + # location on screen + x: int + y: int + redraw*: bool + submitFun: SubmitSelect + bpos: seq[int] + +proc windowChange*(select: var Select, height: int) = + select.maxh = height - 2 + if select.y + select.options.len >= select.maxh: + select.y = height - select.options.len + if select.y < 0: + select.si = -select.y + select.y = 0 + if select.selected.len > 0: + let i = select.selected[0] + if select.si > i: + select.si = i + elif select.si + select.maxh < i: + select.si = max(i - select.maxh, 0) + select.redraw = true + +proc initSelect*(select: var Select, selectResult: SelectResult, + x, y, height: int, submitFun: SubmitSelect) = + select.open = true + select.multiple = selectResult.multiple + select.options = selectResult.options + select.oselected = selectResult.selected + select.selected = selectResult.selected + select.submitFun = submitFun + for opt in select.options.mitems: + opt.mnormalize() + select.maxw = max(select.maxw, opt.width()) + select.x = x + select.y = y + select.windowChange(height) + +# index of option currently under cursor +func hover(select: Select): int = + return select.cursor + select.si + +func dispheight(select: Select): int = + return select.maxh - select.y + +proc `hover=`(select: var Select, i: int) = + let i = clamp(i, 0, select.options.high) + if i >= select.si + select.dispheight: + select.si = i - select.dispheight + 1 + select.cursor = select.dispheight - 1 + elif i < select.si: + select.si = i + select.cursor = 0 + else: + select.cursor = i - select.si + +proc cursorDown*(select: var Select) = + if select.hover < select.options.high and + select.cursor + select.y < select.maxh - 1: + inc select.cursor + select.redraw = true + elif select.si < select.options.len - select.maxh: + inc select.si + select.redraw = true + +proc cursorUp*(select: var Select) = + if select.cursor > 0: + dec select.cursor + select.redraw = true + elif select.si > 0: + dec select.si + select.redraw = true + elif select.multiple and select.cursor > -1: + select.cursor = -1 + +proc close(select: var Select) = + select = Select() + +proc cancel*(select: var Select) = + select.submitFun(select.oselected) + select.close() + +proc submit(select: var Select) = + select.submitFun(select.selected) + select.close() + +proc click*(select: var Select) = + if not select.multiple: + select.selected = @[select.hover] + select.submit() + elif select.cursor == -1: + select.submit() + else: + var k = select.selected.len + let i = select.hover + for j in 0 ..< select.selected.len: + if select.selected[j] >= i: + k = j + break + if k < select.selected.len and select.selected[k] == i: + select.selected.delete(k) + else: + select.selected.insert(i, k) + select.redraw = true + +proc cursorLeft*(select: var Select) = + select.submit() + +proc cursorRight*(select: var Select) = + select.click() + +proc getCursorX*(select: var Select): int = + if select.cursor == -1: + return select.x + return select.x + 1 + +proc getCursorY*(select: var Select): int = + return select.y + 1 + select.cursor + +proc cursorFirstLine*(select: var Select) = + if select.cursor != 0 or select.si != 0: + select.cursor = 0 + select.si = 0 + select.redraw = true + +proc cursorLastLine*(select: var Select) = + if select.hover < select.options.len: + select.cursor = select.dispheight - 1 + select.si = max(select.options.len - select.maxh, 0) + select.redraw = true + +proc cursorNextMatch*(select: var Select, regex: Regex, wrap: bool) = + var j = -1 + for i in select.hover + 1 ..< select.options.len: + if regex.exec(select.options[i]).success: + j = i + break + if j != -1: + select.hover = j + select.redraw = true + elif wrap: + for i in 0 ..< select.hover: + if regex.exec(select.options[i]).success: + j = i + break + if j != -1: + select.hover = j + select.redraw = true + +proc cursorPrevMatch*(select: var Select, regex: Regex, wrap: bool) = + var j = -1 + for i in countdown(select.hover - 1, 0): + if regex.exec(select.options[i]).success: + j = i + break + if j != -1: + select.hover = j + select.redraw = true + elif wrap: + for i in countdown(select.options.high, select.hover): + if regex.exec(select.options[i]).success: + j = i + break + if j != -1: + select.hover = j + select.redraw = true + +proc pushCursorPos*(select: var Select) = + select.bpos.add(select.hover) + +proc popCursorPos*(select: var Select, nojump = false) = + select.hover = select.bpos.pop() + if not nojump: + select.redraw = true + +const HorizontalBar = $Rune(0x2500) +const VerticalBar = $Rune(0x2502) +const CornerTopLeft = $Rune(0x250C) +const CornerTopRight = $Rune(0x2510) +const CornerBottomLeft = $Rune(0x2514) +const CornerBottomRight = $Rune(0x2518) + +proc drawBorders(display: var FixedGrid, sx, ex, sy, ey: int, + upmore, downmore: bool) = + for y in sy .. ey: + var x = 0 + while x < sx: + if display[y * display.width + x].str == "": + display[y * display.width + x].str = " " + inc x + else: + #x = display[y * display.width + x].str.twidth(x) + inc x + # Draw corners. + let tl = if upmore: VerticalBar else: CornerTopLeft + let tr = if upmore: VerticalBar else: CornerTopRight + let bl = if downmore: VerticalBar else: CornerBottomLeft + let br = if downmore: VerticalBar else: CornerBottomRight + const fmt = newFormat() + display[sy * display.width + sx].str = tl + display[sy * display.width + ex].str = tr + display[ey * display.width + sx].str = bl + display[ey * display.width + ex].str = br + display[sy * display.width + sx].format = fmt + display[sy * display.width + ex].format = fmt + display[ey * display.width + sx].format = fmt + display[ey * display.width + ex].format = fmt + # Draw top, bottom borders. + let ups = if upmore: " " else: HorizontalBar + let downs = if downmore: " " else: HorizontalBar + for x in sx + 1 .. ex - 1: + display[sy * display.width + x].str = ups + display[ey * display.width + x].str = downs + display[sy * display.width + x].format = fmt + display[ey * display.width + x].format = fmt + if upmore: + display[sy * display.width + sx + (ex - sx) div 2].str = ":" + if downmore: + display[ey * display.width + sx + (ex - sx) div 2].str = ":" + # Draw left, right borders. + for y in sy + 1 .. ey - 1: + display[y * display.width + sx].str = VerticalBar + display[y * display.width + ex].str = VerticalBar + display[y * display.width + sx].format = fmt + display[y * display.width + ex].format = fmt + +proc drawSelect*(select: Select, display: var FixedGrid) = + if display.width < 2 or display.height < 2: + return # border does not fit... + # Max width, height with one row/column on the sides. + let mw = display.width - 2 + let mh = display.height - 2 + var sy = select.y + let si = select.si + var ey = min(sy + select.options.len, mh) + 1 + var sx = select.x + if sx + select.maxw >= mw: + sx = display.width - select.maxw + if sx < 0: + # This means the widest option is wider than the available screen. + # w3m simply cuts off the part that doesn't fit, and we do that too, + # but I feel like this may not be the best solution. + sx = 0 + var ex = min(sx + select.maxw, mw) + 1 + let upmore = select.si > 0 + let downmore = select.si + mh < select.options.len + drawBorders(display, sx, ex, sy, ey, upmore, downmore) + if select.multiple and not upmore: + display[sy * display.width + sx].str = "X" + # move inside border + inc sy + inc sx + var r: Rune + var k = 0 + var format = newFormat() + while k < select.selected.len and select.selected[k] < si: + inc k + for y in sy ..< ey: + let i = y - sy + si + var j = 0 + var x = sx + let dls = y * display.width + if k < select.selected.len and select.selected[k] == i: + format.reverse = true + inc k + else: + format.reverse = false + while j < select.options[i].len: + fastRuneAt(select.options[i], j, r) + let rw = r.twidth(x) + let ox = x + x += rw + if x > ex: + break + display[dls + ox].str = $r + display[dls + ox].format = format + while x < ex: + display[dls + x].str = " " + display[dls + x].format = format + inc x |