diff --git a/archive.d b/archive.d index 7c3fb87..497d06e 100644 --- a/archive.d +++ b/archive.d @@ -1,11 +1,24 @@ /++ Provides LZMA (aka .xz) and .tar file read-only support. - Possibly more later. + Combine to read .tar.xz files, or use in conjunction with + other files to read other types of .tar files. + + Also has a custom archive called arcz read and write support. + It is designed to efficiently pack and randomly access large + numbers of similar files. Unlike .zip files, it will do + cross-file compression (meaning it can significantly shrink + archives with several small but similar files), and unlike + tar.gz files, it supports random access without decompressing + the whole archive to get an individual file. It is designed + for large numbers of small, similar files. +/ module arsd.archive; -// note to self: i might bring in ketmar's arcz thing in here eventually too. +version(WithoutLzmaDecoder) {} else +version=WithLzmaDecoder; +version(WithoutArczCode) {} else +version=WithArczCode; /+ /++ @@ -196,6 +209,7 @@ ulong readVla(ref ubyte[] data) { BEWARE OF REUSED BUFFERS. See the example. +/ +version(WithLzmaDecoder) struct XzDecoder { /++ Start decoding by feeding it some initial data. You must @@ -372,6 +386,7 @@ struct XzDecoder { } /// +version(WithLzmaDecoder) unittest { void main() { @@ -445,6 +460,1099 @@ unittest { main(); } +version(WithArczCode) { +/* The code in this section was originally written by Ketmar Dark for his arcz.d module. I modified it afterward. */ + +/** ARZ chunked archive format processor. + * + * This module provides `std.stdio.File`-like interface to ARZ archives. + * + * Copyright: Copyright Ketmar Dark, 2016 + * + * License: Boost License 1.0 + */ +// module iv.arcz; + +// use Balz compressor if available +static if (__traits(compiles, { import iv.balz; })) enum arcz_has_balz = true; else enum arcz_has_balz = false; +static if (__traits(compiles, { import iv.zopfli; })) enum arcz_has_zopfli = true; else enum arcz_has_zopfli = false; +static if (arcz_has_balz) import iv.balz; +static if (arcz_has_zopfli) import iv.zopfli; + +// comment this to free pakced chunk buffer right after using +// i.e. `AZFile` will allocate new block for each new chunk +//version = arcz_use_more_memory; + +public import core.stdc.stdio : SEEK_SET, SEEK_CUR, SEEK_END; + + +// ////////////////////////////////////////////////////////////////////////// // +/// ARZ archive accessor. Use this to open ARZ archives, and open packed files from ARZ archives. +public struct ArzArchive { +private: + static assert(size_t.sizeof >= (void*).sizeof); + private import core.stdc.stdio : FILE, fopen, fclose, fread, fseek; + private import etc.c.zlib; + + static struct ChunkInfo { + uint ofs; // offset in file + uint pksize; // packed chunk size (same as chunk size: chunk is unpacked) + } + + static struct FileInfo { + string name; + uint chunk; + uint chunkofs; // offset of first file byte in unpacked chunk + uint size; // unpacked file size + } + + static struct Nfo { + uint rc = 1; // refcounter + ChunkInfo[] chunks; + FileInfo[string] files; + uint chunkSize; + uint lastChunkSize; + bool useBalz; + FILE* afl; // archive file, we'll keep it opened + + @disable this (this); // no copies! + + static void decRef (size_t me) { + if (me) { + auto nfo = cast(Nfo*)me; + assert(nfo.rc); + if (--nfo.rc == 0) { + import core.memory : GC; + import core.stdc.stdlib : free; + if (nfo.afl !is null) fclose(nfo.afl); + nfo.chunks.destroy; + nfo.files.destroy; + nfo.afl = null; + GC.removeRange(cast(void*)nfo/*, Nfo.sizeof*/); + free(nfo); + debug(arcz_rc) { import core.stdc.stdio : printf; printf("Nfo %p freed\n", nfo); } + } + } + } + } + + size_t nfop; // hide it from GC + + private @property Nfo* nfo () { pragma(inline, true); return cast(Nfo*)nfop; } + void decRef () { pragma(inline, true); Nfo.decRef(nfop); nfop = 0; } + + static uint readUint (FILE* fl) { + if (fl is null) throw new Exception("cannot read from closed file"); + uint v; + if (fread(&v, 1, v.sizeof, fl) != v.sizeof) throw new Exception("file reading error"); + version(BigEndian) { + import core.bitop : bswap; + v = bswap(v); + } else version(LittleEndian) { + // nothing to do + } else { + static assert(0, "wtf?!"); + } + return v; + } + + static uint readUbyte (FILE* fl) { + if (fl is null) throw new Exception("cannot read from closed file"); + ubyte v; + if (fread(&v, 1, v.sizeof, fl) != v.sizeof) throw new Exception("file reading error"); + return v; + } + + static void readBuf (FILE* fl, void[] buf) { + if (buf.length > 0) { + if (fl is null) throw new Exception("cannot read from closed file"); + if (fread(buf.ptr, 1, buf.length, fl) != buf.length) throw new Exception("file reading error"); + } + } + + static T* xalloc(T, bool clear=true) (uint mem) if (T.sizeof > 0) { + import core.exception : onOutOfMemoryError; + assert(mem != 0); + static if (clear) { + import core.stdc.stdlib : calloc; + auto res = calloc(mem, T.sizeof); + if (res is null) onOutOfMemoryError(); + static if (is(T == struct)) { + import core.stdc.string : memcpy; + static immutable T i = T.init; + foreach (immutable idx; 0..mem) memcpy(res+idx, &i, T.sizeof); + } + debug(arcz_alloc) { import core.stdc.stdio : printf; printf("allocated %u bytes at %p\n", cast(uint)(mem*T.sizeof), res); } + return cast(T*)res; + } else { + import core.stdc.stdlib : malloc; + auto res = malloc(mem*T.sizeof); + if (res is null) onOutOfMemoryError(); + static if (is(T == struct)) { + import core.stdc.string : memcpy; + static immutable T i = T.init; + foreach (immutable idx; 0..mem) memcpy(res+idx, &i, T.sizeof); + } + debug(arcz_alloc) { import core.stdc.stdio : printf; printf("allocated %u bytes at %p\n", cast(uint)(mem*T.sizeof), res); } + return cast(T*)res; + } + } + + static void xfree(T) (T* ptr) { + if (ptr !is null) { + import core.stdc.stdlib : free; + debug(arcz_alloc) { import core.stdc.stdio : printf; printf("freing at %p\n", ptr); } + free(ptr); + } + } + + static if (arcz_has_balz) static ubyte balzDictSize (uint blockSize) { + foreach (ubyte bits; Balz.MinDictBits..Balz.MaxDictBits+1) { + if ((1U<= blockSize) return bits; + } + return Balz.MaxDictBits; + } + + // unpack exactly `destlen` bytes + static if (arcz_has_balz) static void unpackBlockBalz (void* dest, uint destlen, const(void)* src, uint srclen, uint blocksize) { + Unbalz bz; + bz.reinit(balzDictSize(blocksize)); + int ipos, opos; + auto dc = bz.decompress( + // reader + (buf) { + import core.stdc.string : memcpy; + if (ipos >= srclen) return 0; + uint rd = destlen-ipos; + if (rd > buf.length) rd = cast(uint)buf.length; + memcpy(buf.ptr, src+ipos, rd); + ipos += rd; + return rd; + }, + // writer + (buf) { + //if (opos+buf.length > destlen) throw new Exception("error unpacking archive"); + uint wr = destlen-opos; + if (wr > buf.length) wr = cast(uint)buf.length; + if (wr > 0) { + import core.stdc.string : memcpy; + memcpy(dest+opos, buf.ptr, wr); + opos += wr; + } + }, + // unpack length + destlen + ); + if (opos != destlen) throw new Exception("error unpacking archive"); + } + + static void unpackBlockZLib (void* dest, uint destlen, const(void)* src, uint srclen, uint blocksize) { + z_stream zs; + zs.avail_in = 0; + zs.avail_out = 0; + // initialize unpacker + if (inflateInit2(&zs, 15) != Z_OK) throw new Exception("can't initialize zlib"); + scope(exit) inflateEnd(&zs); + zs.next_in = cast(typeof(zs.next_in))src; + zs.avail_in = srclen; + zs.next_out = cast(typeof(zs.next_out))dest; + zs.avail_out = destlen; + while (zs.avail_out > 0) { + auto err = inflate(&zs, Z_SYNC_FLUSH); + if (err != Z_STREAM_END && err != Z_OK) throw new Exception("error unpacking archive"); + if (err == Z_STREAM_END) break; + } + if (zs.avail_out != 0) throw new Exception("error unpacking archive"); + } + + static void unpackBlock (void* dest, uint destlen, const(void)* src, uint srclen, uint blocksize, bool useBalz) { + if (useBalz) { + static if (arcz_has_balz) { + unpackBlockBalz(dest, destlen, src, srclen, blocksize); + } else { + throw new Exception("no Balz support was compiled in ArcZ"); + } + } else { + unpackBlockZLib(dest, destlen, src, srclen, blocksize); + } + } + +public: + this (in ArzArchive arc) { + assert(nfop == 0); + nfop = arc.nfop; + if (nfop) ++nfo.rc; + } + + this (this) { + if (nfop) ++nfo.rc; + } + + ~this () { close(); } + + void opAssign (in ArzArchive arc) { + if (arc.nfop) { + auto n = cast(Nfo*)arc.nfop; + ++n.rc; + } + decRef(); + nfop = arc.nfop; + } + + void close () { decRef(); } + + @property FileInfo[string] files () { return (nfop ? nfo.files : null); } + + void openArchive (const(char)[] filename) { + debug/*(arcz)*/ import core.stdc.stdio : printf; + FILE* fl = null; + scope(exit) if (fl !is null) fclose(fl); + close(); + if (filename.length == 0) throw new Exception("cannot open unnamed archive file"); + if (false && filename.length < 2048) { // FIXME the alloca fails on win64 for some reason + import core.stdc.stdlib : alloca; + auto tfn = (cast(char*)alloca(filename.length+1))[0..filename.length+1]; + tfn[0..filename.length] = filename[]; + tfn[filename.length] = 0; + fl = fopen(tfn.ptr, "rb"); + } else { + import core.stdc.stdlib : malloc, free; + auto tfn = (cast(char*)malloc(filename.length+1))[0..filename.length+1]; + if (tfn !is null) { + scope(exit) free(tfn.ptr); + fl = fopen(tfn.ptr, "rb"); + } + } + if (fl is null) throw new Exception("cannot open archive file '"~filename.idup~"'"); + char[4] sign; + bool useBalz; + readBuf(fl, sign[]); + if (sign != "CZA2") throw new Exception("invalid archive file '"~filename.idup~"'"); + switch (readUbyte(fl)) { + case 0: useBalz = false; break; + case 1: useBalz = true; break; + default: throw new Exception("invalid version of archive file '"~filename.idup~"'"); + } + uint indexofs = readUint(fl); // index offset in file + uint pkidxsize = readUint(fl); // packed index size + uint idxsize = readUint(fl); // unpacked index size + if (pkidxsize == 0 || idxsize == 0 || indexofs == 0) throw new Exception("invalid archive file '"~filename.idup~"'"); + // now read index + ubyte* idxbuf = null; + scope(exit) xfree(idxbuf); + { + auto pib = xalloc!ubyte(pkidxsize); + scope(exit) xfree(pib); + if (fseek(fl, indexofs, 0) < 0) throw new Exception("seek error in archive file '"~filename.idup~"'"); + readBuf(fl, pib[0..pkidxsize]); + idxbuf = xalloc!ubyte(idxsize); + unpackBlock(idxbuf, idxsize, pib, pkidxsize, idxsize, useBalz); + } + + // parse index and build structures + uint idxbufpos = 0; + + ubyte getUbyte () { + if (idxsize-idxbufpos < ubyte.sizeof) throw new Exception("invalid index for archive file '"~filename.idup~"'"); + return idxbuf[idxbufpos++]; + } + + uint getUint () { + if (idxsize-idxbufpos < uint.sizeof) throw new Exception("invalid index for archive file '"~filename.idup~"'"); + version(BigEndian) { + import core.bitop : bswap; + uint v = *cast(uint*)(idxbuf+idxbufpos); + idxbufpos += 4; + return bswap(v); + } else version(LittleEndian) { + uint v = *cast(uint*)(idxbuf+idxbufpos); + idxbufpos += 4; + return v; + } else { + static assert(0, "wtf?!"); + } + } + + void getBuf (void[] buf) { + if (buf.length > 0) { + import core.stdc.string : memcpy; + if (idxsize-idxbufpos < buf.length) throw new Exception("invalid index for archive file '"~filename.idup~"'"); + memcpy(buf.ptr, idxbuf+idxbufpos, buf.length); + idxbufpos += buf.length; + } + } + + // allocate shared info struct + Nfo* nfo = xalloc!Nfo(1); + assert(nfo.rc == 1); + debug(arcz_rc) { import core.stdc.stdio : printf; printf("Nfo %p allocated\n", nfo); } + scope(failure) decRef(); + nfop = cast(size_t)nfo; + { + import core.memory : GC; + GC.addRange(nfo, Nfo.sizeof); + } + + // read chunk info and data + nfo.useBalz = useBalz; + nfo.chunkSize = getUint; + auto ccount = getUint; // chunk count + nfo.lastChunkSize = getUint; + debug(arcz_dirread) printf("chunk size: %u\nchunk count: %u\nlast chunk size:%u\n", nfo.chunkSize, ccount, nfo.lastChunkSize); + if (ccount == 0 || nfo.chunkSize < 1 || nfo.lastChunkSize < 1 || nfo.lastChunkSize > nfo.chunkSize) throw new Exception("invalid archive file '"~filename.idup~"'"); + nfo.chunks.length = ccount; + // chunk offsets and sizes + foreach (ref ci; nfo.chunks) { + ci.ofs = getUint; + ci.pksize = getUint; + } + // read file count and info + auto fcount = getUint; + if (fcount == 0) throw new Exception("empty archive file '"~filename.idup~"'"); + // calc name buffer position and size + //immutable uint nbofs = idxbufpos+fcount*(5*4); + //if (nbofs >= idxsize) throw new Exception("invalid index in archive file '"~filename.idup~"'"); + //immutable uint nbsize = idxsize-nbofs; + debug(arcz_dirread) printf("file count: %u\n", fcount); + foreach (immutable _; 0..fcount) { + uint nameofs = getUint; + uint namelen = getUint; + if (namelen == 0) { + // skip unnamed file + //throw new Exception("invalid archive file '"~filename.idup~"'"); + getUint; // chunk number + getUint; // offset in chunk + getUint; // unpacked size + debug(arcz_dirread) printf("skipped empty file\n"); + } else { + //if (nameofs >= nbsize || namelen > nbsize || nameofs+namelen > nbsize) throw new Exception("invalid index in archive file '"~filename.idup~"'"); + if (nameofs >= idxsize || namelen > idxsize || nameofs+namelen > idxsize) throw new Exception("invalid index in archive file '"~filename.idup~"'"); + FileInfo fi; + auto nb = new char[](namelen); + nb[0..namelen] = (cast(char*)idxbuf)[nameofs..nameofs+namelen]; + fi.name = cast(string)(nb); // it is safe here + fi.chunk = getUint; // chunk number + fi.chunkofs = getUint; // offset in chunk + fi.size = getUint; // unpacked size + debug(arcz_dirread) printf("file size: %u\nfile chunk: %u\noffset in chunk:%u; name: [%.*s]\n", fi.size, fi.chunk, fi.chunkofs, cast(uint)fi.name.length, fi.name.ptr); + nfo.files[fi.name] = fi; + } + } + // transfer achive file ownership + nfo.afl = fl; + fl = null; + } + + bool exists (const(char)[] name) { if (nfop) return ((name in nfo.files) !is null); else return false; } + + AZFile open (const(char)[] name) { + if (!nfop) throw new Exception("can't open file from non-opened archive"); + if (auto fi = name in nfo.files) { + auto zl = xalloc!LowLevelPackedRO(1); + scope(failure) xfree(zl); + debug(arcz_rc) { import core.stdc.stdio : printf; printf("Zl %p allocated\n", zl); } + zl.setup(nfo, fi.chunk, fi.chunkofs, fi.size); + AZFile fl; + fl.zlp = cast(size_t)zl; + return fl; + } + throw new Exception("can't open file '"~name.idup~"' from archive"); + } + +private: + static struct LowLevelPackedRO { + private import etc.c.zlib; + + uint rc = 1; + size_t nfop; // hide it from GC + + private @property inout(Nfo*) nfo () inout pure nothrow @trusted @nogc { pragma(inline, true); return cast(typeof(return))nfop; } + static void decRef (size_t me) { + if (me) { + auto zl = cast(LowLevelPackedRO*)me; + assert(zl.rc); + if (--zl.rc == 0) { + import core.stdc.stdlib : free; + if (zl.chunkData !is null) free(zl.chunkData); + version(arcz_use_more_memory) if (zl.pkdata !is null) free(zl.pkdata); + Nfo.decRef(zl.nfop); + free(zl); + debug(arcz_rc) { import core.stdc.stdio : printf; printf("Zl %p freed\n", zl); } + } else { + //debug(arcz_rc) { import core.stdc.stdio : printf; printf("Zl %p; rc after decRef is %u\n", zl, zl.rc); } + } + } + } + + uint nextchunk; // next chunk to read + uint curcpos; // position in current chunk + uint curcsize; // number of valid bytes in `chunkData` + uint stchunk; // starting chunk + uint stofs; // offset in starting chunk + uint totalsize; // total file size + uint pos; // current file position + uint lastrdpos; // last actual read position + z_stream zs; + ubyte* chunkData; // can be null + version(arcz_use_more_memory) { + ubyte* pkdata; + uint pkdatasize; + } + + @disable this (this); + + void setup (Nfo* anfo, uint astchunk, uint astofs, uint asize) { + assert(anfo !is null); + assert(rc == 1); + nfop = cast(size_t)anfo; + ++anfo.rc; + nextchunk = stchunk = astchunk; + //curcpos = 0; + stofs = astofs; + totalsize = asize; + } + + @property bool eof () { pragma(inline, true); return (pos >= totalsize); } + + // return less than chunk size if our file fits in one non-full chunk completely + uint justEnoughMemory () pure const nothrow @safe @nogc { + pragma(inline, true); + version(none) { + return nfo.chunkSize; + } else { + return (totalsize < nfo.chunkSize && stofs+totalsize < nfo.chunkSize ? stofs+totalsize : nfo.chunkSize); + } + } + + void unpackNextChunk () { + if (nfop == 0) assert(0, "wtf?!"); + //scope(failure) if (chunkData !is null) { xfree(chunkData); chunkData = null; } + debug(arcz_unp) { import core.stdc.stdio : printf; printf("unpacking chunk %u\n", nextchunk); } + // allocate buffer for unpacked data + if (chunkData is null) { + // optimize things a little: if our file fits in less then one chunk, allocate "just enough" memory + chunkData = xalloc!(ubyte, false)(justEnoughMemory); + } + auto chunk = &nfo.chunks[nextchunk]; + if (chunk.pksize == nfo.chunkSize) { + // unpacked chunk, just read it + debug(arcz_unp) { import core.stdc.stdio : printf; printf(" chunk is not packed\n"); } + if (fseek(nfo.afl, chunk.ofs, 0) < 0) throw new Exception("ARCZ reading error"); + if (fread(chunkData, 1, nfo.chunkSize, nfo.afl) != nfo.chunkSize) throw new Exception("ARCZ reading error"); + curcsize = nfo.chunkSize; + } else { + // packed chunk, unpack it + // allocate buffer for packed data + version(arcz_use_more_memory) { + import core.stdc.stdlib : realloc; + if (pkdatasize < chunk.pksize) { + import core.exception : onOutOfMemoryError; + auto newpk = realloc(pkdata, chunk.pksize); + if (newpk is null) onOutOfMemoryError(); + debug(arcz_alloc) { import core.stdc.stdio : printf; printf("reallocated from %u to %u bytes; %p -> %p\n", cast(uint)pkdatasize, cast(uint)chunk.pksize, pkdata, newpk); } + pkdata = cast(ubyte*)newpk; + pkdatasize = chunk.pksize; + } + alias pkd = pkdata; + } else { + auto pkd = xalloc!(ubyte, false)(chunk.pksize); + scope(exit) xfree(pkd); + } + if (fseek(nfo.afl, chunk.ofs, 0) < 0) throw new Exception("ARCZ reading error"); + if (fread(pkd, 1, chunk.pksize, nfo.afl) != chunk.pksize) throw new Exception("ARCZ reading error"); + uint upsize = (nextchunk == nfo.chunks.length-1 ? nfo.lastChunkSize : nfo.chunkSize); // unpacked chunk size + immutable uint cksz = upsize; + immutable uint jem = justEnoughMemory; + if (upsize > jem) upsize = jem; + debug(arcz_unp) { import core.stdc.stdio : printf; printf(" unpacking %u bytes to %u bytes\n", chunk.pksize, upsize); } + ArzArchive.unpackBlock(chunkData, upsize, pkd, chunk.pksize, cksz, nfo.useBalz); + curcsize = upsize; + } + curcpos = 0; + // fix first chunk offset if necessary + if (nextchunk == stchunk && stofs > 0) { + // it's easier to just memmove it + import core.stdc.string : memmove; + assert(stofs < curcsize); + memmove(chunkData, chunkData+stofs, curcsize-stofs); + curcsize -= stofs; + } + ++nextchunk; // advance to next chunk + } + + void syncReadPos () { + if (pos >= totalsize || pos == lastrdpos) return; + immutable uint fcdata = nfo.chunkSize-stofs; // number of our bytes in the first chunk + // does our pos lie in the first chunk? + if (pos < fcdata) { + // yep, just read it + if (nextchunk != stchunk+1) { + nextchunk = stchunk; + unpackNextChunk(); // we'll need it anyway + } else { + // just rewind + curcpos = 0; + } + curcpos += pos; + lastrdpos = pos; + return; + } + // find the chunk we want + uint npos = pos-fcdata; + uint xblock = stchunk+1+npos/nfo.chunkSize; + uint curcstart = (xblock-(stchunk+1))*nfo.chunkSize+fcdata; + if (xblock != nextchunk-1) { + // read and unpack this chunk + nextchunk = xblock; + unpackNextChunk(); + } else { + // just rewind + curcpos = 0; + } + assert(pos >= curcstart && pos < curcstart+nfo.chunkSize); + uint skip = pos-curcstart; + lastrdpos = pos; + curcpos += skip; + } + + int read (void* buf, uint count) { + if (buf is null) return -1; + if (count == 0 || totalsize == 0) return 0; + if (totalsize >= 0 && pos >= totalsize) return 0; // EOF + syncReadPos(); + assert(lastrdpos == pos); + if (cast(long)pos+count > totalsize) count = totalsize-pos; + auto res = count; + while (count > 0) { + debug(arcz_read) { import core.stdc.stdio : printf; printf("reading %u bytes; pos=%u; lastrdpos=%u; curcpos=%u; curcsize=%u\n", count, pos, lastrdpos, curcpos, curcsize); } + import core.stdc.string : memcpy; + if (curcpos >= curcsize) { + unpackNextChunk(); // we want next chunk! + debug(arcz_read) { import core.stdc.stdio : printf; printf(" *reading %u bytes; pos=%u; lastrdpos=%u; curcpos=%u; curcsize=%u\n", count, pos, lastrdpos, curcpos, curcsize); } + } + assert(curcpos < curcsize && curcsize != 0); + int rd = (curcsize-curcpos >= count ? count : curcsize-curcpos); + assert(rd > 0); + memcpy(buf, chunkData+curcpos, rd); + curcpos += rd; + pos += rd; + lastrdpos += rd; + buf += rd; + count -= rd; + } + assert(pos == lastrdpos); + return res; + } + + long lseek (long ofs, int origin) { + //TODO: overflow checks + switch (origin) { + case SEEK_SET: break; + case SEEK_CUR: ofs += pos; break; + case SEEK_END: + if (ofs > 0) ofs = 0; + if (-ofs > totalsize) ofs = -cast(long)totalsize; + ofs += totalsize; + break; + default: + return -1; + } + if (ofs < 0) return -1; + if (totalsize >= 0 && ofs > totalsize) ofs = totalsize; + pos = cast(uint)ofs; + return pos; + } + } +} + + +// ////////////////////////////////////////////////////////////////////////// // +/// Opened file. +public struct AZFile { +private: + size_t zlp; + + private @property inout(ArzArchive.LowLevelPackedRO)* zl () inout pure nothrow @trusted @nogc { pragma(inline, true); return cast(typeof(return))zlp; } + private void decRef () { pragma(inline, true); ArzArchive.LowLevelPackedRO.decRef(zlp); zlp = 0; } + +public: + this (in AZFile afl) { + assert(zlp == 0); + zlp = afl.zlp; + if (zlp) ++zl.rc; + } + + this (this) { + if (zlp) ++zl.rc; + } + + ~this () { close(); } + + void opAssign (in AZFile afl) { + if (afl.zlp) { + auto n = cast(ArzArchive.LowLevelPackedRO*)afl.zlp; + ++n.rc; + } + decRef(); + zlp = afl.zlp; + } + + void close () { decRef(); } + + @property bool isOpen () const pure nothrow @safe @nogc { pragma(inline, true); return (zlp != 0); } + @property uint size () const pure nothrow @safe @nogc { pragma(inline, true); return (zlp ? zl.totalsize : 0); } + @property uint tell () const pure nothrow @safe @nogc { pragma(inline, true); return (zlp ? zl.pos : 0); } + + void seek (long ofs, int origin=SEEK_SET) { + if (!zlp) throw new Exception("can't seek in closed file"); + auto res = zl.lseek(ofs, origin); + if (res < 0) throw new Exception("seek error"); + } + + private import std.traits : isMutable; + + //TODO: overflow check + T[] rawRead(T) (T[] buf) if (isMutable!T) { + if (!zlp) throw new Exception("can't read from closed file"); + if (buf.length > 0) { + auto res = zl.read(buf.ptr, cast(int) (buf.length*T.sizeof)); + if (res == -1 || res%T.sizeof != 0) throw new Exception("read error"); + return buf[0..res/T.sizeof]; + } else { + return buf[0..0]; + } + } +} + + +// ////////////////////////////////////////////////////////////////////////// // +/** this class can be used to create archive file. + * + * Example: + * -------------------- + * import std.file, std.path, std.stdio : File; + * + * enum ArcName = "z00.arz"; + * enum DirName = "experimental-docs"; + * + * ubyte[] rdbuf; + * rdbuf.length = 65536; + * + * auto arcz = new ArzCreator(ArcName); + * long total = 0; + * foreach (DirEntry e; dirEntries(DirName, SpanMode.breadth)) { + * if (e.isFile) { + * assert(e.size < uint.max); + * //writeln(e.name); + * total += e.size; + * string fname = e.name[DirName.length+1..$]; + * arcz.newFile(fname, cast(uint)e.size); + * auto fi = File(e.name); + * for (;;) { + * auto rd = fi.rawRead(rdbuf[]); + * if (rd.length == 0) break; + * arcz.rawWrite(rd[]); + * } + * } + * } + * arcz.close(); + * writeln(total, " bytes packed to ", getSize(ArcName), " (", arcz.chunksWritten, " chunks, ", arcz.filesWritten, " files)"); + * -------------------- + */ +final class ArzCreator { +private import etc.c.zlib; +private import core.stdc.stdio : FILE, fopen, fclose, ftell, fseek, fwrite; + +public: + //WARNING! don't change the order! + enum Compressor { + ZLib, // default + Balz, + BalzMax, // Balz, maximum compression + Zopfli, // this will fallback to zlib if no zopfli support was compiled in + } + +private: + static struct ChunkInfo { + uint ofs; // offset in file + uint pksize; // packed chunk size + } + + static struct FileInfo { + string name; + uint chunk; + uint chunkofs; // offset of first file byte in unpacked chunk + uint size; // unpacked file size + } + +private: + ubyte[] chunkdata; + uint cdpos; + FILE* arcfl; + ChunkInfo[] chunks; + FileInfo[] files; + uint lastChunkSize; + uint statChunks, statFiles; + Compressor cpr = Compressor.ZLib; + +private: + void writeUint (uint v) { + if (arcfl is null) throw new Exception("write error"); + version(BigEndian) { + import core.bitop : bswap; + v = bswap(v); + } else version(LittleEndian) { + // nothing to do + } else { + static assert(0, "wtf?!"); + } + if (fwrite(&v, 1, v.sizeof, arcfl) != v.sizeof) throw new Exception("write error"); // signature + } + + void writeUbyte (ubyte v) { + if (arcfl is null) throw new Exception("write error"); + if (fwrite(&v, 1, v.sizeof, arcfl) != v.sizeof) throw new Exception("write error"); // signature + } + + void writeBuf (const(void)[] buf) { + if (buf.length > 0) { + if (arcfl is null) throw new Exception("write error"); + if (fwrite(buf.ptr, 1, buf.length, arcfl) != buf.length) throw new Exception("write error"); // signature + } + } + + static if (arcz_has_balz) long writePackedBalz (const(void)[] upbuf) { + assert(upbuf.length > 0 && upbuf.length < int.max); + long res = 0; + Balz bz; + int ipos, opos; + bz.reinit(ArzArchive.balzDictSize(cast(uint)upbuf.length)); + bz.compress( + // reader + (buf) { + import core.stdc.string : memcpy; + if (ipos >= upbuf.length) return 0; + uint rd = cast(uint)upbuf.length-ipos; + if (rd > buf.length) rd = cast(uint)buf.length; + memcpy(buf.ptr, upbuf.ptr+ipos, rd); + ipos += rd; + return rd; + }, + // writer + (buf) { + res += buf.length; + writeBuf(buf[]); + }, + // max mode + (cpr == Compressor.BalzMax) + ); + return res; + } + + static if (arcz_has_zopfli) long writePackedZopfli (const(void)[] upbuf) { + ubyte[] indata; + void* odata; + size_t osize; + ZopfliOptions opts; + ZopfliCompress(opts, ZOPFLI_FORMAT_ZLIB, upbuf.ptr, upbuf.length, &odata, &osize); + writeBuf(odata[0..osize]); + ZopfliFree(odata); + return cast(long)osize; + } + + long writePackedZLib (const(void)[] upbuf) { + assert(upbuf.length > 0 && upbuf.length < int.max); + long res = 0; + z_stream zs; + ubyte[2048] obuf; + zs.next_out = obuf.ptr; + zs.avail_out = cast(uint)obuf.length; + zs.next_in = null; + zs.avail_in = 0; + // initialize packer + if (deflateInit2(&zs, Z_BEST_COMPRESSION, Z_DEFLATED, 15, 9, 0) != Z_OK) throw new Exception("can't write packed data"); + scope(exit) deflateEnd(&zs); + zs.next_in = cast(typeof(zs.next_in))upbuf.ptr; + zs.avail_in = cast(uint)upbuf.length; + while (zs.avail_in > 0) { + if (zs.avail_out == 0) { + res += cast(uint)obuf.length; + writeBuf(obuf[]); + zs.next_out = obuf.ptr; + zs.avail_out = cast(uint)obuf.length; + } + auto err = deflate(&zs, Z_NO_FLUSH); + if (err != Z_OK) throw new Exception("zlib compression error"); + } + while (zs.avail_out != obuf.length) { + res += cast(uint)obuf.length-zs.avail_out; + writeBuf(obuf[0..$-zs.avail_out]); + zs.next_out = obuf.ptr; + zs.avail_out = cast(uint)obuf.length; + auto err = deflate(&zs, Z_FINISH); + if (err != Z_OK && err != Z_STREAM_END) throw new Exception("zlib compression error"); + // succesfully flushed? + //if (err != Z_STREAM_END) throw new VFSException("zlib compression error"); + } + return res; + } + + // return size of packed data written + uint writePackedBuf (const(void)[] upbuf) { + assert(upbuf.length > 0 && upbuf.length < int.max); + long res = 0; + final switch (cpr) { + case Compressor.ZLib: + res = writePackedZLib(upbuf); + break; + case Compressor.Balz: + case Compressor.BalzMax: + static if (arcz_has_balz) { + res = writePackedBalz(upbuf); + break; + } else { + throw new Exception("no Balz support was compiled in ArcZ"); + } + case Compressor.Zopfli: + static if (arcz_has_zopfli) { + res = writePackedZopfli(upbuf); + //break; + } else { + //new Exception("no Zopfli support was compiled in ArcZ"); + res = writePackedZLib(upbuf); + } + break; + } + if (res > uint.max) throw new Exception("output archive too big"); + return cast(uint)res; + } + + void flushData () { + if (cdpos > 0) { + ChunkInfo ci; + auto pos = ftell(arcfl); + if (pos < 0 || pos >= uint.max) throw new Exception("output archive too big"); + ci.ofs = cast(uint)pos; + auto wlen = writePackedBuf(chunkdata[0..cdpos]); + ci.pksize = wlen; + if (cdpos == chunkdata.length && ci.pksize >= chunkdata.length) { + // wow, this chunk is unpackable + //{ import std.stdio; writeln("unpackable chunk found!"); } + if (fseek(arcfl, pos, 0) < 0) throw new Exception("can't seek in output file"); + writeBuf(chunkdata[0..cdpos]); + version(Posix) { + import core.stdc.stdio : fileno; + import core.sys.posix.unistd : ftruncate; + pos = ftell(arcfl); + if (pos < 0 || pos >= uint.max) throw new Exception("output archive too big"); + if (ftruncate(fileno(arcfl), cast(uint)pos) < 0) throw new Exception("error truncating output file"); + } + ci.pksize = cdpos; + } + if (cdpos < chunkdata.length) lastChunkSize = cast(uint)cdpos; + cdpos = 0; + chunks ~= ci; + } else { + lastChunkSize = cast(uint)chunkdata.length; + } + } + + void closeArc () { + flushData(); + // write index + //assert(ftell(arcfl) > 0 && ftell(arcfl) < uint.max); + assert(chunkdata.length < uint.max); + assert(chunks.length < uint.max); + assert(files.length < uint.max); + // create index in memory + ubyte[] index; + + void putUint (uint v) { + index ~= v&0xff; + index ~= (v>>8)&0xff; + index ~= (v>>16)&0xff; + index ~= (v>>24)&0xff; + } + + void putUbyte (ubyte v) { + index ~= v; + } + + void putBuf (const(void)[] buf) { + assert(buf.length > 0); + index ~= (cast(const(ubyte)[])buf)[]; + } + + // create index in memory + { + // chunk size + putUint(cast(uint)chunkdata.length); + // chunk count + putUint(cast(uint)chunks.length); + // last chunk size + putUint(lastChunkSize); // 0: last chunk is full + // chunk offsets and sizes + foreach (ref ci; chunks) { + putUint(ci.ofs); + putUint(ci.pksize); + } + // file count + putUint(cast(uint)files.length); + uint nbofs = cast(uint)index.length+cast(uint)files.length*(5*4); + //uint nbofs = 0; + // files + foreach (ref fi; files) { + // name: length(byte), chars + assert(fi.name.length > 0 && fi.name.length <= 16384); + putUint(nbofs); + putUint(cast(uint)fi.name.length); + nbofs += cast(uint)fi.name.length+1; // put zero byte there to ease C interfacing + //putBuf(fi.name[]); + // chunk number + putUint(fi.chunk); + // offset in unpacked chunk + putUint(fi.chunkofs); + // unpacked size + putUint(fi.size); + } + // names + foreach (ref fi; files) { + putBuf(fi.name[]); + putUbyte(0); // this means nothing, it is here just for convenience (hello, C!) + } + assert(index.length < uint.max); + } + auto cpos = ftell(arcfl); + if (cpos < 0 || cpos > uint.max) throw new Exception("output archive too big"); + // write packed index + debug(arcz_writer) { import core.stdc.stdio : pinrtf; printf("index size: %u\n", cast(uint)index.length); } + auto pkisz = writePackedBuf(index[]); + debug(arcz_writer) { import core.stdc.stdio : pinrtf; printf("packed index size: %u\n", cast(uint)pkisz); } + // write index info + if (fseek(arcfl, 5, 0) < 0) throw new Exception("seek error"); + // index offset in file + writeUint(cast(uint) cpos); + // packed index size + writeUint(pkisz); + // unpacked index size + writeUint(cast(uint)index.length); + // done + statChunks = cast(uint)chunks.length; + statFiles = cast(uint)files.length; + } + +public: + this (const(char)[] fname, uint chunkSize=256*1024, Compressor acpr=Compressor.ZLib) { + import std.internal.cstring; + assert(chunkSize > 0 && chunkSize < 32*1024*1024); // arbitrary limit + static if (!arcz_has_balz) { + if (acpr == Compressor.Balz || acpr == Compressor.BalzMax) throw new Exception("no Balz support was compiled in ArcZ"); + } + static if (!arcz_has_zopfli) { + //if (acpr == Compressor.Zopfli) throw new Exception("no Zopfli support was compiled in ArcZ"); + } + cpr = acpr; + arcfl = fopen(fname.tempCString, "wb"); + if (arcfl is null) throw new Exception("can't create output file '"~fname.idup~"'"); + cdpos = 0; + chunkdata.length = chunkSize; + scope(failure) { fclose(arcfl); arcfl = null; } + writeBuf("CZA2"); // signature + if (cpr == Compressor.Balz || cpr == Compressor.BalzMax) { + writeUbyte(1); // version + } else { + writeUbyte(0); // version + } + writeUint(0); // offset to index + writeUint(0); // packed index size + writeUint(0); // unpacked index size + } + + ~this () { close(); } + + void close () { + if (arcfl !is null) { + scope(exit) { fclose(arcfl); arcfl = null; } + closeArc(); + } + chunkdata = null; + chunks = null; + files = null; + lastChunkSize = 0; + cdpos = 0; + } + + // valid after closing + @property uint chunksWritten () const pure nothrow @safe @nogc { pragma(inline, true); return statChunks; } + @property uint filesWritten () const pure nothrow @safe @nogc { pragma(inline, true); return statFiles; } + + void newFile (string name, uint size) { + FileInfo fi; + assert(name.length <= 255); + fi.name = name; + fi.chunk = cast(uint)chunks.length; + fi.chunkofs = cast(uint)cdpos; + fi.size = size; + files ~= fi; + } + + void rawWrite(T) (const(T)[] buffer) { + if (buffer.length > 0) { + auto src = cast(const(ubyte)*)buffer.ptr; + auto len = buffer.length*T.sizeof; + while (len > 0) { + if (cdpos == chunkdata.length) flushData(); + if (cdpos < chunkdata.length) { + auto wr = chunkdata.length-cdpos; + if (wr > len) wr = len; + chunkdata[cdpos..cdpos+wr] = src[0..wr]; + cdpos += wr; + len -= wr; + src += wr; + } + } + } + } +} + + +// ////////////////////////////////////////////////////////////////////////// // +/* arcz file format: +header +====== +db 'CZA2' ; signature +db version ; 0: zlib; 1: balz +dd indexofs ; offset to packed index +dd pkindexsz ; size of packed index +dd upindexsz ; size of unpacked index + + +index +===== +dd chunksize ; unpacked chunk size in bytes +dd chunkcount ; number of chunks in file +dd lastchunksz ; size of last chunk (it may be incomplete); 0: last chunk is completely used (all `chunksize` bytes) + +then chunk offsets and sizes follows: + dd chunkofs ; from file start + dd pkchunksz ; size of (possibly packed) chunk data; if it equals to `chunksize`, this chunk is not packed + +then file list follows: +dd filecount ; number of files in archive + +then file info follows: + dd nameofs ; (in index) + dd namelen ; length of name (can't be 0) + dd firstchunk ; chunk where file starts + dd firstofs ; offset in first chunk (unpacked) where file starts + dd filesize ; unpacked file size + +then name buffer follows -- just bytes +*/ + +} + +version(WithLzmaDecoder) { /* *************************************************** */ /* The rest of the file is copy/paste of external code */ @@ -2138,3 +3246,5 @@ SRes Lzma2Decode(Byte *dest, SizeT *destLen, Byte *src, SizeT *srcLen, LzmaDec_FreeProbs(&decoder.decoder); return res; } + +} diff --git a/cgi.d b/cgi.d index cf97eee..caee996 100644 --- a/cgi.d +++ b/cgi.d @@ -495,6 +495,12 @@ mixin template ForwardCgiConstructors() { this(BufferedInputRange ir, bool* closeConnection) { super(ir, closeConnection); } } +/// thrown when a connection is closed remotely while we waiting on data from it +class ConnectionClosedException : Exception { + this(string message, string file = __FILE__, size_t line = __LINE__, Throwable next = null) { + super(message, file, line, next); + } +} version(Windows) { @@ -1594,7 +1600,11 @@ class Cgi { // that check for UnixAddress is to work around a Phobos bug // see: https://github.com/dlang/phobos/pull/7383 + // but this might be more useful anyway tbh for this case + version(Posix) this(ir, cast(UnixAddress) ira ? "unix:" : ira.toString(), 80 /* FIXME */, 0, false, &rdo, null, closeConnection); + else + this(ir, ira.toString(), 80 /* FIXME */, 0, false, &rdo, null, closeConnection); } /** @@ -1995,9 +2005,20 @@ class Cgi { setCache(true); // need to enable caching so the date has meaning responseIsPublic = isPublic; + responseExpiresRelative = false; + } + + /// Sets a cache-control max-age header for whenFromNow, in seconds. + void setResponseExpiresRelative(int whenFromNow, bool isPublic = false) { + responseExpires = whenFromNow; + setCache(true); // need to enable caching so the date has meaning + + responseIsPublic = isPublic; + responseExpiresRelative = true; } private long responseExpires = long.min; private bool responseIsPublic = false; + private bool responseExpiresRelative = false; /// This is like setResponseExpires, but it can be called multiple times. The setting most in the past is the one kept. /// If you have multiple functions, they all might call updateResponseExpires about their own return value. The program @@ -2108,11 +2129,15 @@ class Cgi { hd ~= "Location: " ~ responseLocation; } if(!noCache && responseExpires != long.min) { // an explicit expiration date is set - auto expires = SysTime(unixTimeToStdTime(cast(int)(responseExpires / 1000)), UTC()); - hd ~= "Expires: " ~ printDate( - cast(DateTime) expires); - // FIXME: assuming everything is private unless you use nocache - generally right for dynamic pages, but not necessarily - hd ~= "Cache-Control: "~(responseIsPublic ? "public" : "private")~", no-cache=\"set-cookie, set-cookie2\""; + if(responseExpiresRelative) { + hd ~= "Cache-Control: "~(responseIsPublic ? "public" : "private")~", max-age="~to!string(responseExpires)~", no-cache=\"set-cookie, set-cookie2\""; + } else { + auto expires = SysTime(unixTimeToStdTime(cast(int)(responseExpires / 1000)), UTC()); + hd ~= "Expires: " ~ printDate( + cast(DateTime) expires); + // FIXME: assuming everything is private unless you use nocache - generally right for dynamic pages, but not necessarily + hd ~= "Cache-Control: "~(responseIsPublic ? "public" : "private")~", no-cache=\"set-cookie, set-cookie2\""; + } } if(responseCookies !is null && responseCookies.length > 0) { foreach(c; responseCookies) @@ -3949,19 +3974,20 @@ class BufferedInputRange { */ void popFront(size_t maxBytesToConsume = 0 /*size_t.max*/, size_t minBytesToSettleFor = 0, bool skipConsume = false) { if(sourceClosed) - throw new Exception("can't get any more data from a closed source"); + throw new ConnectionClosedException("can't get any more data from a closed source"); if(!skipConsume) consume(maxBytesToConsume); // we might have to grow the buffer if(minBytesToSettleFor > underlyingBuffer.length || view.length == underlyingBuffer.length) { if(allowGrowth) { - import std.stdio; writeln("growth"); + //import std.stdio; writeln("growth"); auto viewStart = view.ptr - underlyingBuffer.ptr; size_t growth = 4096; // make sure we have enough for what we're being asked for - if(minBytesToSettleFor - underlyingBuffer.length > growth) + if(minBytesToSettleFor > 0 && minBytesToSettleFor - underlyingBuffer.length > growth) growth = minBytesToSettleFor - underlyingBuffer.length; + //import std.stdio; writeln(underlyingBuffer.length, " ", viewStart, " ", view.length, " ", growth, " ", minBytesToSettleFor, " ", minBytesToSettleFor - underlyingBuffer.length); underlyingBuffer.length += growth; view = underlyingBuffer[viewStart .. view.length]; } else @@ -4638,6 +4664,37 @@ version(cgi_with_websocket) { // returns true if data available, false if it timed out bool recvAvailable(Duration timeout = dur!"msecs"(0)) { + if(!waitForNextMessageWouldBlock()) + return true; + if(isDataPending(timeout)) + return true; // this is kinda a lie. + + return false; + } + + public bool lowLevelReceive() { + auto bfr = cgi.idlol; + top: + auto got = bfr.front; + if(got.length) { + if(receiveBuffer.length < receiveBufferUsedLength + got.length) + receiveBuffer.length += receiveBufferUsedLength + got.length; + + receiveBuffer[receiveBufferUsedLength .. receiveBufferUsedLength + got.length] = got[]; + receiveBufferUsedLength += got.length; + bfr.consume(got.length); + + return true; + } + + bfr.popFront(0); + if(bfr.sourceClosed) + return false; + goto top; + } + + + bool isDataPending(Duration timeout = 0.seconds) { Socket socket = cgi.idlol.source; auto check = new SocketSet(); @@ -4650,47 +4707,297 @@ version(cgi_with_websocket) { } // note: this blocks - WebSocketMessage recv() { - // FIXME: should we automatically handle pings and pongs? - if(cgi.idlol.empty()) - throw new Exception("remote side disconnected"); - cgi.idlol.popFront(0); - - WebSocketMessage message; - - message = WebSocketMessage.read(cgi.idlol); - - return message; + WebSocketFrame recv() { + return waitForNextMessage(); } - void send(in char[] text) { - // I cast away const here because I know this msg is private and it doesn't write - // to that buffer unless masking is set... which it isn't, so we're ok. - auto msg = WebSocketMessage.simpleMessage(WebSocketOpcode.text, cast(void[]) text); - msg.send(cgi); + + + + private void llclose() { + cgi.close(); } - void send(in ubyte[] binary) { - // I cast away const here because I know this msg is private and it doesn't write - // to that buffer unless masking is set... which it isn't, so we're ok. - auto msg = WebSocketMessage.simpleMessage(WebSocketOpcode.binary, cast(void[]) binary); - msg.send(cgi); + private void llsend(ubyte[] data) { + cgi.write(data); + cgi.flush(); } - void close() { - auto msg = WebSocketMessage.simpleMessage(WebSocketOpcode.close, null); - msg.send(cgi); + void unregisterActiveSocket(WebSocket) {} + + /* copy/paste section { */ + + private int readyState_; + private ubyte[] receiveBuffer; + private size_t receiveBufferUsedLength; + + private Config config; + + enum CONNECTING = 0; /// Socket has been created. The connection is not yet open. + enum OPEN = 1; /// The connection is open and ready to communicate. + enum CLOSING = 2; /// The connection is in the process of closing. + enum CLOSED = 3; /// The connection is closed or couldn't be opened. + + /++ + + +/ + /// Group: foundational + static struct Config { + /++ + These control the size of the receive buffer. + + It starts at the initial size, will temporarily + balloon up to the maximum size, and will reuse + a buffer up to the likely size. + + Anything larger than the maximum size will cause + the connection to be aborted and an exception thrown. + This is to protect you against a peer trying to + exhaust your memory, while keeping the user-level + processing simple. + +/ + size_t initialReceiveBufferSize = 4096; + size_t likelyReceiveBufferSize = 4096; /// ditto + size_t maximumReceiveBufferSize = 10 * 1024 * 1024; /// ditto + + /++ + Maximum combined size of a message. + +/ + size_t maximumMessageSize = 10 * 1024 * 1024; + + string[string] cookies; /// Cookies to send with the initial request. cookies[name] = value; + string origin; /// Origin URL to send with the handshake, if desired. + string protocol; /// the protocol header, if desired. + + int pingFrequency = 5000; /// Amount of time (in msecs) of idleness after which to send an automatic ping } + /++ + Returns one of [CONNECTING], [OPEN], [CLOSING], or [CLOSED]. + +/ + int readyState() { + return readyState_; + } + + /++ + Closes the connection, sending a graceful teardown message to the other side. + +/ + /// Group: foundational + void close(int code = 0, string reason = null) + //in (reason.length < 123) + in { assert(reason.length < 123); } do + { + if(readyState_ != OPEN) + return; // it cool, we done + WebSocketFrame wss; + wss.fin = true; + wss.opcode = WebSocketOpcode.close; + wss.data = cast(ubyte[]) reason; + wss.send(&llsend); + + readyState_ = CLOSING; + + llclose(); + } + + /++ + Sends a ping message to the server. This is done automatically by the library if you set a non-zero [Config.pingFrequency], but you can also send extra pings explicitly as well with this function. + +/ + /// Group: foundational void ping() { - auto msg = WebSocketMessage.simpleMessage(WebSocketOpcode.ping, null); - msg.send(cgi); + WebSocketFrame wss; + wss.fin = true; + wss.opcode = WebSocketOpcode.ping; + wss.send(&llsend); } + // automatically handled.... void pong() { - auto msg = WebSocketMessage.simpleMessage(WebSocketOpcode.pong, null); - msg.send(cgi); + WebSocketFrame wss; + wss.fin = true; + wss.opcode = WebSocketOpcode.pong; + wss.send(&llsend); } + + /++ + Sends a text message through the websocket. + +/ + /// Group: foundational + void send(in char[] textData) { + WebSocketFrame wss; + wss.fin = true; + wss.opcode = WebSocketOpcode.text; + wss.data = cast(ubyte[]) textData; + wss.send(&llsend); + } + + /++ + Sends a binary message through the websocket. + +/ + /// Group: foundational + void send(in ubyte[] binaryData) { + WebSocketFrame wss; + wss.fin = true; + wss.opcode = WebSocketOpcode.binary; + wss.data = cast(ubyte[]) binaryData; + wss.send(&llsend); + } + + /++ + Waits for and returns the next complete message on the socket. + + Note that the onmessage function is still called, right before + this returns. + +/ + /// Group: blocking_api + public WebSocketFrame waitForNextMessage() { + do { + auto m = processOnce(); + if(m.populated) + return m; + } while(lowLevelReceive()); + + return WebSocketFrame.init; // FIXME? maybe. + } + + /++ + Tells if [waitForNextMessage] would block. + +/ + /// Group: blocking_api + public bool waitForNextMessageWouldBlock() { + checkAgain: + if(isMessageBuffered()) + return false; + if(!isDataPending()) + return true; + while(isDataPending()) + lowLevelReceive(); + goto checkAgain; + } + + /++ + Is there a message in the buffer already? + If `true`, [waitForNextMessage] is guaranteed to return immediately. + If `false`, check [isDataPending] as the next step. + +/ + /// Group: blocking_api + public bool isMessageBuffered() { + ubyte[] d = receiveBuffer[0 .. receiveBufferUsedLength]; + auto s = d; + if(d.length) { + auto orig = d; + auto m = WebSocketFrame.read(d); + // that's how it indicates that it needs more data + if(d !is orig) + return true; + } + + return false; + } + + private ubyte continuingType; + private ubyte[] continuingData; + //private size_t continuingDataLength; + + private WebSocketFrame processOnce() { + ubyte[] d = receiveBuffer[0 .. receiveBufferUsedLength]; + auto s = d; + // FIXME: handle continuation frames more efficiently. it should really just reuse the receive buffer. + WebSocketFrame m; + if(d.length) { + auto orig = d; + m = WebSocketFrame.read(d); + // that's how it indicates that it needs more data + if(d is orig) + return WebSocketFrame.init; + switch(m.opcode) { + case WebSocketOpcode.continuation: + if(continuingData.length + m.data.length > config.maximumMessageSize) + throw new Exception("message size exceeded"); + + continuingData ~= m.data; + if(m.fin) { + if(ontextmessage) + ontextmessage(cast(char[]) continuingData); + if(onbinarymessage) + onbinarymessage(continuingData); + + continuingData = null; + } + break; + case WebSocketOpcode.text: + if(m.fin) { + if(ontextmessage) + ontextmessage(m.textData); + } else { + continuingType = m.opcode; + //continuingDataLength = 0; + continuingData = null; + continuingData ~= m.data; + } + break; + case WebSocketOpcode.binary: + if(m.fin) { + if(onbinarymessage) + onbinarymessage(m.data); + } else { + continuingType = m.opcode; + //continuingDataLength = 0; + continuingData = null; + continuingData ~= m.data; + } + break; + case WebSocketOpcode.close: + readyState_ = CLOSED; + if(onclose) + onclose(); + + unregisterActiveSocket(this); + break; + case WebSocketOpcode.ping: + pong(); + break; + case WebSocketOpcode.pong: + // just really references it is still alive, nbd. + break; + default: // ignore though i could and perhaps should throw too + } + } + receiveBufferUsedLength -= s.length - d.length; + + return m; + } + + private void autoprocess() { + // FIXME + do { + processOnce(); + } while(lowLevelReceive()); + } + + + void delegate() onclose; /// + void delegate() onerror; /// + void delegate(in char[]) ontextmessage; /// + void delegate(in ubyte[]) onbinarymessage; /// + void delegate() onopen; /// + + /++ + + +/ + /// Group: browser_api + void onmessage(void delegate(in char[]) dg) { + ontextmessage = dg; + } + + /// ditto + void onmessage(void delegate(in ubyte[]) dg) { + onbinarymessage = dg; + } + + /* } end copy/paste */ + + } bool websocketRequested(Cgi cgi) { @@ -4729,10 +5036,11 @@ version(cgi_with_websocket) { return new WebSocket(cgi); } - // FIXME: implement websocket extension frames - // get websocket to work on other modes, not just embedded_httpd + // FIXME get websocket to work on other modes, not just embedded_httpd + /* copy/paste in http2.d { */ enum WebSocketOpcode : ubyte { + continuation = 0, text = 1, binary = 2, // 3, 4, 5, 6, 7 RESERVED @@ -4742,7 +5050,8 @@ version(cgi_with_websocket) { // 11,12,13,14,15 RESERVED } - struct WebSocketMessage { + public struct WebSocketFrame { + private bool populated; bool fin; bool rsv1; bool rsv2; @@ -4754,8 +5063,8 @@ version(cgi_with_websocket) { ubyte[4] maskingKey; // don't set this when sending ubyte[] data; - static WebSocketMessage simpleMessage(WebSocketOpcode opcode, void[] data) { - WebSocketMessage msg; + static WebSocketFrame simpleMessage(WebSocketOpcode opcode, void[] data) { + WebSocketFrame msg; msg.fin = true; msg.opcode = opcode; msg.data = cast(ubyte[]) data; @@ -4763,7 +5072,7 @@ version(cgi_with_websocket) { return msg; } - private void send(Cgi cgi) { + private void send(scope void delegate(ubyte[]) llsend) { ubyte[64] headerScratch; int headerScratchPos = 0; @@ -4819,7 +5128,7 @@ version(cgi_with_websocket) { headerScratch[1] = b2; } - assert(!masked, "masking key not properly implemented"); + //assert(!masked, "masking key not properly implemented"); if(masked) { // FIXME: randomize this headerScratch[headerScratchPos .. headerScratchPos + 4] = maskingKey[]; @@ -4837,25 +5146,27 @@ version(cgi_with_websocket) { } //writeln("SENDING ", headerScratch[0 .. headerScratchPos], data); - cgi.write(headerScratch[0 .. headerScratchPos]); - cgi.write(data); - cgi.flush(); + llsend(headerScratch[0 .. headerScratchPos]); + llsend(data); } - static WebSocketMessage read(BufferedInputRange ir) { + static WebSocketFrame read(ref ubyte[] d) { + WebSocketFrame msg; - auto d = ir.front(); - while(d.length < 2) { - ir.popFront(); - d = ir.front(); + auto orig = d; + + WebSocketFrame needsMoreData() { + d = orig; + return WebSocketFrame.init; } - auto start = d; - WebSocketMessage msg; - assert(d.length >= 2); + if(d.length < 2) + return needsMoreData(); ubyte b = d[0]; + msg.populated = true; + msg.opcode = cast(WebSocketOpcode) (b & 0x0f); b >>= 4; msg.rsv3 = b & 0x01; @@ -4876,6 +5187,8 @@ version(cgi_with_websocket) { // 16 bit length msg.realLength = 0; + if(d.length < 2) return needsMoreData(); + foreach(i; 0 .. 2) { msg.realLength |= d[0] << ((1-i) * 8); d = d[1 .. $]; @@ -4884,6 +5197,8 @@ version(cgi_with_websocket) { // 64 bit length msg.realLength = 0; + if(d.length < 8) return needsMoreData(); + foreach(i; 0 .. 8) { msg.realLength |= d[0] << ((7-i) * 8); d = d[1 .. $]; @@ -4894,15 +5209,19 @@ version(cgi_with_websocket) { } if(msg.masked) { + + if(d.length < 4) return needsMoreData(); + msg.maskingKey = d[0 .. 4]; d = d[4 .. $]; } - //if(d.length < msg.realLength) { + if(msg.realLength > d.length) { + return needsMoreData(); + } - //} - msg.data = d[0 .. msg.realLength]; - d = d[msg.realLength .. $]; + msg.data = d[0 .. cast(size_t) msg.realLength]; + d = d[cast(size_t) msg.realLength .. $]; if(msg.masked) { // let's just unmask it now @@ -4916,8 +5235,6 @@ version(cgi_with_websocket) { } } - ir.consume(start.length - d.length); - return msg; } @@ -4925,7 +5242,7 @@ version(cgi_with_websocket) { return cast(char[]) data; } } - + /* } */ } diff --git a/color.d b/color.d index bc08b63..74558d7 100644 --- a/color.d +++ b/color.d @@ -258,7 +258,12 @@ struct Color { throw new Exception("Unknown color " ~ s); } - /// Reads a CSS style string to get the color. Understands #rrggbb, rgba(), hsl(), and rrggbbaa + /++ + Reads a CSS style string to get the color. Understands #rrggbb, rgba(), hsl(), and rrggbbaa + + History: + The short-form hex string parsing (`#fff`) was added on April 10, 2020. (v7.2.0) + +/ static Color fromString(scope const(char)[] s) { s = s.stripInternal(); @@ -334,6 +339,17 @@ struct Color { if(s.length && s[0] == '#') s = s[1 .. $]; + // support short form #fff for example + if(s.length == 3 || s.length == 4) { + string n; + n.reserve(8); + foreach(ch; s) { + n ~= ch; + n ~= ch; + } + s = n; + } + // not a built in... do it as a hex string if(s.length >= 2) { c.r = fromHexInternal(s[0 .. 2]); @@ -417,6 +433,15 @@ struct Color { } } +unittest { + Color c = Color.fromString("#fff"); + assert(c == Color.white); + assert(c == Color.fromString("#ffffff")); + + c = Color.fromString("#f0f"); + assert(c == Color.fromString("rgb(255, 0, 255)")); +} + nothrow @safe private string toHexInternal(ubyte b) { string s; diff --git a/dom.d b/dom.d index b3b20e8..5e9045b 100644 --- a/dom.d +++ b/dom.d @@ -5,6 +5,8 @@ // FIXME: the scriptable list is quite arbitrary +// FIXME: https://developer.mozilla.org/en-US/docs/Web/CSS/:is + // xml entity references?! diff --git a/dub.json b/dub.json index 2f33418..7abc030 100644 --- a/dub.json +++ b/dub.json @@ -75,11 +75,12 @@ "dependencies": { "arsd-official:simpledisplay":"*", "arsd-official:image_files":"*", + "arsd-official:svg":"*", "arsd-official:ttf":"*" }, "importPaths": ["."], "libs-posix": ["freetype", "fontconfig"], - "sourceFiles": ["nanovega.d", "blendish.d", "svg.d"] + "sourceFiles": ["nanovega.d", "blendish.d"] }, { "name": "email", @@ -97,17 +98,36 @@ "targetType": "library", "dependencies": { "arsd-official:color_base":"*", - "arsd-official:png":"*" + "arsd-official:png":"*", + "arsd-official:bmp":"*", + "arsd-official:jpeg":"*" }, "dflags": [ "-mv=arsd.image=image.d", - "-mv=arsd.bmp=bmp.d", - "-mv=arsd.jpeg=jpeg.d", "-mv=arsd.targa=targa.d", "-mv=arsd.pcx=pcx.d", "-mv=arsd.dds=dds.d" ], - "sourceFiles": ["image.d", "bmp.d", "jpeg.d", "targa.d", "pcx.d", "dds.d"] + "sourceFiles": ["image.d", "targa.d", "pcx.d", "dds.d"] + }, + { + "name": "svg", + "description": "Dependency-free partial SVG file format read support", + "importPaths": ["."], + "targetType": "library", + "dflags": ["-mv=arsd.svg=svg.d"], + "sourceFiles": ["svg.d"] + }, + { + "name": "jpeg", + "description": "Dependency-free partial JPEG file format read support", + "importPaths": ["."], + "targetType": "library", + "dflags": ["-mv=arsd.jpeg=jpeg.d"], + "dependencies": { + "arsd-official:color_base":"*" + }, + "sourceFiles": ["jpeg.d"] }, { "name": "png", @@ -289,7 +309,37 @@ "targetType": "library", "sourceFiles": ["terminal.d"], "importPaths": ["."], - "dflags": ["-mv=arsd.terminal=terminal.d"] + "dflags": ["-mv=arsd.terminal=terminal.d"], + "configurations": [ + { + "name": "normal" + }, + { + "name": "builtin_emulator", + "versions": ["TerminalDirectToEmulator"], + "dependencies": { + "arsd-official:terminalemulator": "*", + "arsd-official:minigui": "*", + + "arsd-official:png":"*", + "arsd-official:jpeg":"*", + "arsd-official:svg":"*", + "arsd-official:bmp":"*" + } + } + ] + }, + { + "name": "terminalemulator", + "description": "A terminal emulation core as an in-memory library. Also includes mixin templates to assist with creating UIs, etc.", + "targetType": "library", + "importPaths": ["."], + "libs-posix": ["util"], + "sourceFiles": ["terminalemulator.d"], + "dflags": ["-mv=arsd.terminalemulator=terminalemulator.d"], + "dependencies": { + "arsd-official:color_base":"*" + } }, { "name": "ttf", @@ -332,6 +382,14 @@ "sourceFiles": ["eventloop.d"], "importPaths": ["."], "dflags": ["-mv=arsd.eventloop=eventloop.d"] + }, + { + "name": "archive", + "description": "Archive file support - tar, tar.xz decoders, and custom format \"arcz\" encoding and decoding. Self-contained.", + "targetType": "library", + "sourceFiles": ["archive.d"], + "importPaths": ["."], + "dflags": ["-mv=arsd.archive=archive.d"] } ] diff --git a/html.d b/html.d index 5dba1c3..eb260fe 100644 --- a/html.d +++ b/html.d @@ -515,7 +515,7 @@ void translateDateInputs(Document document) { /// finds class="striped" and adds class="odd"/class="even" to the relevant /// children void translateStriping(Document document) { - foreach(item; document.getElementsBySelector(".striped")) { + foreach(item; document.querySelectorAll(".striped")) { bool odd = false; string selector; switch(item.tagName) { @@ -545,7 +545,7 @@ void translateStriping(Document document) { /// tries to make an input to filter a list. it kinda sucks. void translateFiltering(Document document) { - foreach(e; document.getElementsBySelector("input[filter_what]")) { + foreach(e; document.querySelectorAll("input[filter_what]")) { auto filterWhat = e.attrs.filter_what; if(filterWhat[0] == '#') filterWhat = filterWhat[1..$]; diff --git a/http2.d b/http2.d index c1d95d8..7d570c1 100644 --- a/http2.d +++ b/http2.d @@ -1017,7 +1017,8 @@ class HttpRequest { } else if(got == 0) { // remote side disconnected debug(arsd_http2) writeln("remote disconnect"); - request.state = State.aborted; + if(request.state != State.complete) + request.state = State.aborted; inactive[inactiveCount++] = sock; sock.close(); loseSocket(request.requestParameters.host, request.requestParameters.port, request.requestParameters.ssl, sock); @@ -1148,7 +1149,7 @@ class HttpRequest { if(colon == -1) return; auto name = header[0 .. colon]; - if(colon + 1 == header.length) + if(colon + 1 == header.length || colon + 2 == header.length) // assuming a space there return; // empty header, idk assert(colon + 2 < header.length, header); auto value = header[colon + 2 .. $]; // skipping the colon itself and the following space @@ -1378,7 +1379,7 @@ class HttpRequest { } if(followLocation && responseData.location.length) { static bool first = true; - if(!first) asm { int 3; } + //version(DigitalMars) if(!first) asm { int 3; } populateFromInfo(Uri(responseData.location), HttpVerb.GET); import std.stdio; writeln("redirected to ", responseData.location); first = false; @@ -2191,66 +2192,13 @@ class WebSocket { private ushort port; private bool ssl; - private int readyState_; - - private Socket socket; - private ubyte[] receiveBuffer; - private size_t receiveBufferUsedLength; - - private Config config; - - enum CONNECTING = 0; /// Socket has been created. The connection is not yet open. - enum OPEN = 1; /// The connection is open and ready to communicate. - enum CLOSING = 2; /// The connection is in the process of closing. - enum CLOSED = 3; /// The connection is closed or couldn't be opened. - /++ - - +/ - /// Group: foundational - static struct Config { - /++ - These control the size of the receive buffer. - - It starts at the initial size, will temporarily - balloon up to the maximum size, and will reuse - a buffer up to the likely size. - - Anything larger than the maximum size will cause - the connection to be aborted and an exception thrown. - This is to protect you against a peer trying to - exhaust your memory, while keeping the user-level - processing simple. - +/ - size_t initialReceiveBufferSize = 4096; - size_t likelyReceiveBufferSize = 4096; /// ditto - size_t maximumReceiveBufferSize = 10 * 1024 * 1024; /// ditto - - /++ - Maximum combined size of a message. - +/ - size_t maximumMessageSize = 10 * 1024 * 1024; - - string[string] cookies; /// Cookies to send with the initial request. cookies[name] = value; - string origin; /// Origin URL to send with the handshake, if desired. - string protocol; /// the protocol header, if desired. - - int pingFrequency = 5000; /// Amount of time (in msecs) of idleness after which to send an automatic ping - } - - /++ - Returns one of [CONNECTING], [OPEN], [CLOSING], or [CLOSED]. - +/ - int readyState() { - return readyState_; - } - - /++ -wss://echo.websocket.org + wss://echo.websocket.org +/ /// Group: foundational this(Uri uri, Config config = Config.init) - in (uri.scheme == "ws" || uri.scheme == "wss") + //in (uri.scheme == "ws" || uri.scheme == "wss") + in { assert(uri.scheme == "ws" || uri.scheme == "wss"); } do { this.uri = uri; this.config = config; @@ -2439,12 +2387,137 @@ wss://echo.websocket.org registerActiveSocket(this); } + /++ + Is data pending on the socket? Also check [isMessageBuffered] to see if there + is already a message in memory too. + + If this returns `true`, you can call [lowLevelReceive], then try [isMessageBuffered] + again. + +/ + /// Group: blocking_api + public bool isDataPending(Duration timeout = 0.seconds) { + static SocketSet readSet; + if(readSet is null) + readSet = new SocketSet(); + + version(with_openssl) + if(auto s = cast(SslClientSocket) socket) { + // select doesn't handle the case with stuff + // left in the ssl buffer so i'm checking it separately + if(s.dataPending()) { + return true; + } + } + + readSet.add(socket); + + //tryAgain: + auto selectGot = Socket.select(readSet, null, null, timeout); + if(selectGot == 0) { /* timeout */ + // timeout + return false; + } else if(selectGot == -1) { /* interrupted */ + return false; + } else { /* ready */ + if(readSet.isSet(socket)) { + return true; + } + } + + return false; + } + + private void llsend(ubyte[] d) { + while(d.length) { + auto r = socket.send(d); + if(r <= 0) throw new Exception("wtf"); + d = d[r .. $]; + } + } + + private void llclose() { + socket.shutdown(SocketShutdown.SEND); + } + + /++ + Waits for more data off the low-level socket and adds it to the pending buffer. + + Returns `true` if the connection is still active. + +/ + /// Group: blocking_api + public bool lowLevelReceive() { + auto r = socket.receive(receiveBuffer[receiveBufferUsedLength .. $]); + if(r == 0) + return false; + if(r <= 0) + throw new Exception("wtf"); + receiveBufferUsedLength += r; + return true; + } + + private Socket socket; + + /* copy/paste section { */ + + private int readyState_; + private ubyte[] receiveBuffer; + private size_t receiveBufferUsedLength; + + private Config config; + + enum CONNECTING = 0; /// Socket has been created. The connection is not yet open. + enum OPEN = 1; /// The connection is open and ready to communicate. + enum CLOSING = 2; /// The connection is in the process of closing. + enum CLOSED = 3; /// The connection is closed or couldn't be opened. + + /++ + + +/ + /// Group: foundational + static struct Config { + /++ + These control the size of the receive buffer. + + It starts at the initial size, will temporarily + balloon up to the maximum size, and will reuse + a buffer up to the likely size. + + Anything larger than the maximum size will cause + the connection to be aborted and an exception thrown. + This is to protect you against a peer trying to + exhaust your memory, while keeping the user-level + processing simple. + +/ + size_t initialReceiveBufferSize = 4096; + size_t likelyReceiveBufferSize = 4096; /// ditto + size_t maximumReceiveBufferSize = 10 * 1024 * 1024; /// ditto + + /++ + Maximum combined size of a message. + +/ + size_t maximumMessageSize = 10 * 1024 * 1024; + + string[string] cookies; /// Cookies to send with the initial request. cookies[name] = value; + string origin; /// Origin URL to send with the handshake, if desired. + string protocol; /// the protocol header, if desired. + + int pingFrequency = 5000; /// Amount of time (in msecs) of idleness after which to send an automatic ping + } + + /++ + Returns one of [CONNECTING], [OPEN], [CLOSING], or [CLOSED]. + +/ + int readyState() { + return readyState_; + } + /++ Closes the connection, sending a graceful teardown message to the other side. +/ /// Group: foundational void close(int code = 0, string reason = null) - in (reason.length < 123) + //in (reason.length < 123) + in { assert(reason.length < 123); } do { if(readyState_ != OPEN) return; // it cool, we done @@ -2456,7 +2529,7 @@ wss://echo.websocket.org readyState_ = CLOSING; - socket.shutdown(SocketShutdown.SEND); + llclose(); } /++ @@ -2502,31 +2575,6 @@ wss://echo.websocket.org wss.send(&llsend); } - - private void llsend(ubyte[] d) { - while(d.length) { - auto r = socket.send(d); - if(r <= 0) throw new Exception("wtf"); - d = d[r .. $]; - } - } - - /++ - Waits for more data off the low-level socket and adds it to the pending buffer. - - Returns `true` if the connection is still active. - +/ - /// Group: blocking_api - public bool lowLevelReceive() { - auto r = socket.receive(receiveBuffer[receiveBufferUsedLength .. $]); - if(r == 0) - return false; - if(r <= 0) - throw new Exception("wtf"); - receiveBufferUsedLength += r; - return true; - } - /++ Waits for and returns the next complete message on the socket. @@ -2579,46 +2627,6 @@ wss://echo.websocket.org return false; } - /++ - Is data pending on the socket? Also check [isMessageBuffered] to see if there - is already a message in memory too. - - If this returns `true`, you can call [lowLevelReceive], then try [isMessageBuffered] - again. - +/ - /// Group: blocking_api - public bool isDataPending() { - static SocketSet readSet; - if(readSet is null) - readSet = new SocketSet(); - - version(with_openssl) - if(auto s = cast(SslClientSocket) socket) { - // select doesn't handle the case with stuff - // left in the ssl buffer so i'm checking it separately - if(s.dataPending()) { - return true; - } - } - - readSet.add(socket); - - //tryAgain: - auto selectGot = Socket.select(readSet, null, null, 0.seconds /* timeout */); - if(selectGot == 0) { /* timeout */ - // timeout - return false; - } else if(selectGot == -1) { /* interrupted */ - return false; - } else { /* ready */ - if(readSet.isSet(socket)) { - return true; - } - } - - return false; - } - private ubyte continuingType; private ubyte[] continuingData; //private size_t continuingDataLength; @@ -2719,6 +2727,8 @@ wss://echo.websocket.org onbinarymessage = dg; } + /* } end copy/paste */ + /* const int bufferedAmount // amount pending const string extensions @@ -2738,6 +2748,8 @@ wss://echo.websocket.org if(readSet is null) readSet = new SocketSet(); + loopExited = false; + outermost: while(!loopExited) { readSet.reset(); @@ -2799,7 +2811,7 @@ wss://echo.websocket.org } /* copy/paste from cgi.d */ -private { +public { enum WebSocketOpcode : ubyte { continuation = 0, text = 1, @@ -3004,3 +3016,36 @@ private { } } } + +/+ + so the url params are arguments. it knows the request + internally. other params are properties on the req + + names may have different paths... those will just add ForSomething i think. + + auto req = api.listMergeRequests + req.page = 10; + + or + req.page(1) + .bar("foo") + + req.execute(); + + + everything in the response is nullable access through the + dynamic object, just with property getters there. need to make + it static generated tho + + other messages may be: isPresent and getDynamic + + + AND/OR what about doing it like the rails objects + + BroadcastMessage.get(4) + // various properties + + // it lists what you updated + + BroadcastMessage.foo().bar().put(5) ++/ diff --git a/jni.d b/jni.d index cd3d3be..76db786 100644 --- a/jni.d +++ b/jni.d @@ -1240,7 +1240,7 @@ private enum ImportImplementationString = q{ auto len = (*env).GetStringLength(env, jret); auto ptr = (*env).GetStringChars(env, jret, null); - static if(is(T == wstring)) { + static if(is(typeof(return) == wstring)) { if(ptr !is null) { ret = ptr[0 .. len].idup; (*env).ReleaseStringChars(env, jret, ptr); diff --git a/minigui.d b/minigui.d index 3ab5ec6..18e217f 100644 --- a/minigui.d +++ b/minigui.d @@ -1093,7 +1093,7 @@ version(win32_widgets) { p.simpleWindowWrappingHwnd.beingOpenKeepsAppOpen = false; Widget.nativeMapping[p.hwnd] = p; - p.originalWindowProcedure = cast(WNDPROC) SetWindowLong(p.hwnd, GWL_WNDPROC, cast(LONG) &HookedWndProc); + p.originalWindowProcedure = cast(WNDPROC) SetWindowLongPtr(p.hwnd, GWL_WNDPROC, cast(size_t) &HookedWndProc); EnumChildWindows(p.hwnd, &childHandler, cast(LPARAM) cast(void*) p); @@ -1113,7 +1113,7 @@ extern(Windows) BOOL childHandler(HWND hwnd, LPARAM lparam) { p.hwnd = hwnd; p.implicitlyCreated = true; Widget.nativeMapping[p.hwnd] = p; - p.originalWindowProcedure = cast(WNDPROC) SetWindowLong(p.hwnd, GWL_WNDPROC, cast(LONG) &HookedWndProc); + p.originalWindowProcedure = cast(WNDPROC) SetWindowLongPtr(p.hwnd, GWL_WNDPROC, cast(size_t) &HookedWndProc); return true; } @@ -1153,6 +1153,39 @@ class Widget { deprecated("Change ScreenPainter to WidgetPainter") final void paint(ScreenPainter) { assert(0, "Change ScreenPainter to WidgetPainter and recompile your code"); } + Menu contextMenu(int x, int y) { return null; } + + final bool showContextMenu(int x, int y, int screenX = -2, int screenY = -2) { + if(parentWindow is null || parentWindow.win is null) return false; + + auto menu = this.contextMenu(x, y); + if(menu is null) + return false; + + version(win32_widgets) { + // FIXME: if it is -1, -1, do it at the current selection location instead + // tho the corner of the window, whcih it does now, isn't the literal worst. + + if(screenX < 0 && screenY < 0) { + auto p = this.globalCoordinates(); + if(screenX == -2) + p.x += x; + if(screenY == -2) + p.y += y; + + screenX = p.x; + screenY = p.y; + } + + if(!TrackPopupMenuEx(menu.handle, 0, screenX, screenY, parentWindow.win.impl.hwnd, null)) + throw new Exception("TrackContextMenuEx"); + } else version(custom_widgets) { + menu.popup(this, x, y); + } + + return true; + } + /// @scriptable void removeWidget() { @@ -1713,7 +1746,7 @@ class OpenGlWidget : Widget { version(win32_widgets) { Widget.nativeMapping[win.hwnd] = this; - this.originalWindowProcedure = cast(WNDPROC) SetWindowLong(win.hwnd, GWL_WNDPROC, cast(LONG) &HookedWndProc); + this.originalWindowProcedure = cast(WNDPROC) SetWindowLongPtr(win.hwnd, GWL_WNDPROC, cast(size_t) &HookedWndProc); } else { win.setEventHandlers( (MouseEvent e) { @@ -1862,6 +1895,7 @@ enum ScrollBarShowPolicy { /++ FIXME ScrollBarShowPolicy +FIXME: use the ScrollMessageWidget in here now that it exists +/ class ScrollableWidget : Widget { // FIXME: make line size configurable @@ -2092,7 +2126,6 @@ class ScrollableWidget : Widget { } else version(win32_widgets) { recomputeChildLayout(); } else static assert(0); - } /// @@ -2291,13 +2324,53 @@ private class ScrollableContainerWidget : Widget { horizontalScrollBar.showing_ = false; verticalScrollBar.showing_ = false; - horizontalScrollBar.addEventListener(EventType.change, () { + horizontalScrollBar.addEventListener("scrolltonextline", { + horizontalScrollBar.setPosition(horizontalScrollBar.position + 1); sw.horizontalScrollTo(horizontalScrollBar.position); }); - verticalScrollBar.addEventListener(EventType.change, () { + horizontalScrollBar.addEventListener("scrolltopreviousline", { + horizontalScrollBar.setPosition(horizontalScrollBar.position - 1); + sw.horizontalScrollTo(horizontalScrollBar.position); + }); + verticalScrollBar.addEventListener("scrolltonextline", { + verticalScrollBar.setPosition(verticalScrollBar.position + 1); sw.verticalScrollTo(verticalScrollBar.position); }); - + verticalScrollBar.addEventListener("scrolltopreviousline", { + verticalScrollBar.setPosition(verticalScrollBar.position - 1); + sw.verticalScrollTo(verticalScrollBar.position); + }); + horizontalScrollBar.addEventListener("scrolltonextpage", { + horizontalScrollBar.setPosition(horizontalScrollBar.position + horizontalScrollBar.step_); + sw.horizontalScrollTo(horizontalScrollBar.position); + }); + horizontalScrollBar.addEventListener("scrolltopreviouspage", { + horizontalScrollBar.setPosition(horizontalScrollBar.position - horizontalScrollBar.step_); + sw.horizontalScrollTo(horizontalScrollBar.position); + }); + verticalScrollBar.addEventListener("scrolltonextpage", { + verticalScrollBar.setPosition(verticalScrollBar.position + verticalScrollBar.step_); + sw.verticalScrollTo(verticalScrollBar.position); + }); + verticalScrollBar.addEventListener("scrolltopreviouspage", { + verticalScrollBar.setPosition(verticalScrollBar.position - verticalScrollBar.step_); + sw.verticalScrollTo(verticalScrollBar.position); + }); + horizontalScrollBar.addEventListener("scrolltoposition", (Event event) { + horizontalScrollBar.setPosition(event.intValue); + sw.horizontalScrollTo(horizontalScrollBar.position); + }); + verticalScrollBar.addEventListener("scrolltoposition", (Event event) { + verticalScrollBar.setPosition(event.intValue); + sw.verticalScrollTo(verticalScrollBar.position); + }); + horizontalScrollBar.addEventListener("scrolltrack", (Event event) { + horizontalScrollBar.setPosition(event.intValue); + sw.horizontalScrollTo(horizontalScrollBar.position); + }); + verticalScrollBar.addEventListener("scrolltrack", (Event event) { + verticalScrollBar.setPosition(event.intValue); + }); super(parent); } @@ -2399,13 +2472,27 @@ abstract class ScrollbarBase : Widget { private int step_ = 16; private int position_; + /// + bool atEnd() { + return position_ + viewableArea_ >= max_; + } + + /// + bool atStart() { + return position_ == 0; + } + /// void setViewableArea(int a) { viewableArea_ = a; + version(custom_widgets) + redraw(); } /// void setMax(int a) { max_ = a; + version(custom_widgets) + redraw(); } /// int max() { @@ -2413,7 +2500,15 @@ abstract class ScrollbarBase : Widget { } /// void setPosition(int a) { + if(a == int.max) + a = max; position_ = max ? a : 0; + if(position_ + viewableArea_ > max) + position_ = max - viewableArea_; + if(position_ < 0) + position_ = 0; + version(custom_widgets) + redraw(); } /// int position() { @@ -2428,6 +2523,7 @@ abstract class ScrollbarBase : Widget { return step_; } + // FIXME: remove this.... maybe protected void informProgramThatUserChangedPosition(int n) { position_ = n; auto evt = new Event(EventType.change, this); @@ -2561,6 +2657,8 @@ class MouseTrackingWidget : Widget { redraw(); }); + int lpx, lpy; + addEventListener(EventType.mousemove, (Event event) { auto oh = hovering; if(event.clientX >= positionX && event.clientX < positionX + thumbWidth && event.clientY >= positionY && event.clientY < positionY + thumbHeight) { @@ -2589,8 +2687,13 @@ class MouseTrackingWidget : Widget { if(positionY < 0) positionY = 0; - auto evt = new Event(EventType.change, this); - evt.sendDirectly(); + if(positionX != lpx || positionY != lpy) { + auto evt = new Event(EventType.change, this); + evt.sendDirectly(); + + lpx = positionX; + lpy = positionY; + } redraw(); }); @@ -2608,8 +2711,8 @@ class MouseTrackingWidget : Widget { } } -version(custom_widgets) -private +//version(custom_widgets) +//private class HorizontalScrollbar : ScrollbarBase { version(custom_widgets) { @@ -2626,11 +2729,13 @@ class HorizontalScrollbar : ScrollbarBase { version(win32_widgets) { SCROLLINFO info; info.cbSize = info.sizeof; - info.nPage = a; + info.nPage = a + 1; info.fMask = SIF_PAGE; SetScrollInfo(hwnd, SB_CTL, &info, true); } else version(custom_widgets) { - // intentionally blank + thumb.positionX = thumbPosition; + thumb.thumbWidth = thumbSize; + thumb.redraw(); } else static assert(0); } @@ -2644,6 +2749,10 @@ class HorizontalScrollbar : ScrollbarBase { info.nMax = max; info.fMask = SIF_RANGE; SetScrollInfo(hwnd, SB_CTL, &info, true); + } else version(custom_widgets) { + thumb.positionX = thumbPosition; + thumb.thumbWidth = thumbSize; + thumb.redraw(); } } @@ -2676,11 +2785,19 @@ class HorizontalScrollbar : ScrollbarBase { auto rightButton = new ArrowButton(ArrowDirection.right, vl); rightButton.setClickRepeat(scrollClickRepeatInterval); + leftButton.tabStop = false; + rightButton.tabStop = false; + thumb.tabStop = false; + leftButton.addEventListener(EventType.triggered, () { - informProgramThatUserChangedPosition(position - step()); + auto ev = new Event("scrolltopreviousline", this); + ev.dispatch(); + //informProgramThatUserChangedPosition(position - step()); }); rightButton.addEventListener(EventType.triggered, () { - informProgramThatUserChangedPosition(position + step()); + auto ev = new Event("scrolltonextline", this); + ev.dispatch(); + //informProgramThatUserChangedPosition(position + step()); }); thumb.thumbWidth = this.minWidth; @@ -2688,7 +2805,11 @@ class HorizontalScrollbar : ScrollbarBase { thumb.addEventListener(EventType.change, () { auto sx = thumb.positionX * max() / thumb.width; - informProgramThatUserChangedPosition(sx); + //informProgramThatUserChangedPosition(sx); + + auto ev = new Event("scrolltoposition", this); + ev.intValue = sx; + ev.dispatch(); }); } } @@ -2698,8 +2819,8 @@ class HorizontalScrollbar : ScrollbarBase { override int minWidth() { return 48; } } -version(custom_widgets) -private +//version(custom_widgets) +//private class VerticalScrollbar : ScrollbarBase { version(custom_widgets) { @@ -2716,11 +2837,13 @@ class VerticalScrollbar : ScrollbarBase { version(win32_widgets) { SCROLLINFO info; info.cbSize = info.sizeof; - info.nPage = a; + info.nPage = a + 1; info.fMask = SIF_PAGE; SetScrollInfo(hwnd, SB_CTL, &info, true); } else version(custom_widgets) { - // intentionally blank + thumb.positionY = thumbPosition; + thumb.thumbHeight = thumbSize; + thumb.redraw(); } else static assert(0); } @@ -2734,6 +2857,10 @@ class VerticalScrollbar : ScrollbarBase { info.nMax = max; info.fMask = SIF_RANGE; SetScrollInfo(hwnd, SB_CTL, &info, true); + } else version(custom_widgets) { + thumb.positionY = thumbPosition; + thumb.thumbHeight = thumbSize; + thumb.redraw(); } } @@ -2767,10 +2894,14 @@ class VerticalScrollbar : ScrollbarBase { downButton.setClickRepeat(scrollClickRepeatInterval); upButton.addEventListener(EventType.triggered, () { - informProgramThatUserChangedPosition(position - step()); + auto ev = new Event("scrolltopreviousline", this); + ev.dispatch(); + //informProgramThatUserChangedPosition(position - step()); }); downButton.addEventListener(EventType.triggered, () { - informProgramThatUserChangedPosition(position + step()); + auto ev = new Event("scrolltonextline", this); + ev.dispatch(); + //informProgramThatUserChangedPosition(position + step()); }); thumb.thumbWidth = this.minWidth; @@ -2779,8 +2910,16 @@ class VerticalScrollbar : ScrollbarBase { thumb.addEventListener(EventType.change, () { auto sy = thumb.positionY * max() / thumb.height; - informProgramThatUserChangedPosition(sy); + auto ev = new Event("scrolltoposition", this); + ev.intValue = sy; + ev.dispatch(); + + //informProgramThatUserChangedPosition(sy); }); + + upButton.tabStop = false; + downButton.tabStop = false; + thumb.tabStop = false; } } @@ -3302,6 +3441,143 @@ class HorizontalLayout : Layout { } +/++ + A widget that takes your widget, puts scroll bars around it, and sends + messages to it when the user scrolls. Unlike [ScrollableWidget], it makes + no effort to automatically scroll or clip its child widgets - it just sends + the messages. ++/ +class ScrollMessageWidget : Widget { + this(Widget parent = null) { + super(parent); + + container = new Widget(this); + hsb = new HorizontalScrollbar(this); + vsb = new VerticalScrollbar(this); + + hsb.addEventListener("scrolltonextline", { + hsb.setPosition(hsb.position + 1); + notify(); + }); + hsb.addEventListener("scrolltopreviousline", { + hsb.setPosition(hsb.position - 1); + notify(); + }); + vsb.addEventListener("scrolltonextline", { + vsb.setPosition(vsb.position + 1); + notify(); + }); + vsb.addEventListener("scrolltopreviousline", { + vsb.setPosition(vsb.position - 1); + notify(); + }); + hsb.addEventListener("scrolltonextpage", { + hsb.setPosition(hsb.position + hsb.step_); + notify(); + }); + hsb.addEventListener("scrolltopreviouspage", { + hsb.setPosition(hsb.position - hsb.step_); + notify(); + }); + vsb.addEventListener("scrolltonextpage", { + vsb.setPosition(vsb.position + vsb.step_); + notify(); + }); + vsb.addEventListener("scrolltopreviouspage", { + vsb.setPosition(vsb.position - vsb.step_); + notify(); + }); + hsb.addEventListener("scrolltoposition", (Event event) { + hsb.setPosition(event.intValue); + notify(); + }); + vsb.addEventListener("scrolltoposition", (Event event) { + vsb.setPosition(event.intValue); + notify(); + }); + + + tabStop = false; + container.tabStop = false; + magic = true; + } + + /// + VerticalScrollbar verticalScrollBar() { return vsb; } + /// + HorizontalScrollbar horizontalScrollBar() { return hsb; } + + void notify() { + auto event = new Event("scroll", this); + event.dispatch(); + } + + /// + Point position() { + return Point(hsb.position, vsb.position); + } + + /// + void setPosition(int x, int y) { + hsb.setPosition(x); + vsb.setPosition(y); + } + + /// + void setPageSize(int unitsX, int unitsY) { + hsb.setStep(unitsX); + vsb.setStep(unitsY); + } + + /// + void setTotalArea(int width, int height) { + hsb.setMax(width); + vsb.setMax(height); + } + + /// + void setViewableArea(int width, int height) { + hsb.setViewableArea(width); + vsb.setViewableArea(height); + } + + private bool magic; + override void addChild(Widget w, int position = int.max) { + if(magic) + container.addChild(w, position); + else + super.addChild(w, position); + } + + override void recomputeChildLayout() { + if(hsb is null || vsb is null || container is null) return; + + registerMovement(); + + hsb.height = 16; // FIXME? are tese 16s sane? + hsb.x = 0; + hsb.y = this.height - hsb.height; + hsb.width = this.width - 16; + hsb.recomputeChildLayout(); + + vsb.width = 16; // FIXME? + vsb.x = this.width - vsb.width; + vsb.y = 0; + vsb.height = this.height - 16; + vsb.recomputeChildLayout(); + + container.x = 0; + container.y = 0; + container.width = this.width - vsb.width; + container.height = this.height - hsb.height; + container.recomputeChildLayout(); + } + + HorizontalScrollbar hsb; + VerticalScrollbar vsb; + Widget container; +} + /++ Bypasses automatic layout for its children, using manual positioning and sizing only. While you need to manually position them, you must ensure they are inside the StaticLayout's @@ -3471,6 +3747,97 @@ class Window : Widget { if(hwnd !is this.win.impl.hwnd) return 1; // we don't care... switch(msg) { + + case WM_VSCROLL, WM_HSCROLL: + auto pos = HIWORD(wParam); + auto m = LOWORD(wParam); + + auto scrollbarHwnd = cast(HWND) lParam; + + + if(auto widgetp = scrollbarHwnd in Widget.nativeMapping) { + + //auto smw = cast(ScrollMessageWidget) widgetp.parent; + + switch(m) { + /+ + // I don't think those messages are ever actually sent normally by the widget itself, + // they are more used for the keyboard interface. methinks. + case SB_BOTTOM: + import std.stdio; writeln("end"); + auto event = new Event("scrolltoend", *widgetp); + event.dispatch(); + //if(!event.defaultPrevented) + break; + case SB_TOP: + import std.stdio; writeln("top"); + auto event = new Event("scrolltobeginning", *widgetp); + event.dispatch(); + break; + case SB_ENDSCROLL: + // idk + break; + +/ + case SB_LINEDOWN: + auto event = new Event("scrolltonextline", *widgetp); + event.dispatch(); + break; + case SB_LINEUP: + auto event = new Event("scrolltopreviousline", *widgetp); + event.dispatch(); + break; + case SB_PAGEDOWN: + auto event = new Event("scrolltonextpage", *widgetp); + event.dispatch(); + break; + case SB_PAGEUP: + auto event = new Event("scrolltopreviouspage", *widgetp); + event.dispatch(); + break; + case SB_THUMBPOSITION: + auto event = new Event("scrolltoposition", *widgetp); + event.intValue = pos; + event.dispatch(); + break; + case SB_THUMBTRACK: + // eh kinda lying but i like the real time update display + auto event = new Event("scrolltoposition", *widgetp); + event.intValue = pos; + event.dispatch(); + // the event loop doesn't seem to carry on with a requested redraw.. + // so we request it to get our dirty bit set... + // then we need to immediately actually redraw it too for instant feedback to user + if(redrawRequested) + actualRedraw(); + break; + default: + } + } else { + return 1; + } + break; + + case WM_CONTEXTMENU: + auto hwndFrom = cast(HWND) wParam; + + auto xPos = cast(short) LOWORD(lParam); + auto yPos = cast(short) HIWORD(lParam); + + if(auto widgetp = hwndFrom in Widget.nativeMapping) { + POINT p; + p.x = xPos; + p.y = yPos; + ScreenToClient(hwnd, &p); + auto clientX = cast(ushort) p.x; + auto clientY = cast(ushort) p.y; + + auto wap = widgetAtPoint(*widgetp, clientX, clientY); + + if(!wap.widget.showContextMenu(wap.x, wap.y, xPos, yPos)) + return 1; // it didn't show above, pass message on + } + break; + case WM_NOTIFY: auto hdr = cast(NMHDR*) lParam; auto hwndFrom = hdr.hwndFrom; @@ -4003,9 +4370,13 @@ class MainWindow : Window { } void setMenuAndToolbarFromAnnotatedCode_internal(T)(ref T t) { Action[] toolbarActions; - auto menuBar = new MenuBar(); + auto menuBar = this.menuBar is null ? new MenuBar() : this.menuBar; Menu[string] mcs; + foreach(menu; menuBar.subMenus) { + mcs[menu.label] = menu; + } + void delegate() triggering; foreach(memberName; __traits(derivedMembers, T)) { @@ -4126,6 +4497,12 @@ class MainWindow : Window { MenuBar menuBar() { return _menu; } /// MenuBar menuBar(MenuBar m) { + if(m is _menu) { + version(custom_widgets) + recomputeChildLayout(); + return m; + } + if(_menu !is null) { // make sure it is sanely removed // FIXME @@ -4402,6 +4779,7 @@ class ToolButton : Button { /// class MenuBar : Widget { MenuItem[] items; + Menu[] subMenus; version(win32_widgets) { HMENU handle; @@ -4440,7 +4818,10 @@ class MenuBar : Widget { /// Menu addItem(Menu item) { - auto mbItem = new MenuItem(item.label, this.parentWindow); + + subMenus ~= item; + + auto mbItem = new MenuItem(item.label, null);// this.parentWindow); // I'ma add the child down below so hopefully this isn't too insane addChild(mbItem); items ~= mbItem; @@ -4845,23 +5226,27 @@ class Menu : Window { else version(custom_widgets) { SimpleWindow dropDown; Widget menuParent; - void popup(Widget parent) { + void popup(Widget parent, int offsetX = 0, int offsetY = int.min) { this.menuParent = parent; - auto w = 150; - auto h = paddingTop + paddingBottom; - Widget previousChild; - foreach(child; this.children) { - h += child.minHeight(); - h += mymax(child.marginTop(), previousChild ? previousChild.marginBottom() : 0); - previousChild = child; + int w = 150; + int h = paddingTop + paddingBottom; + if(this.children.length) { + // hacking it to get the ideal height out of recomputeChildLayout + this.width = w; + this.height = h; + this.recomputeChildLayout(); + h = this.children[$-1].y + this.children[$-1].height + this.children[$-1].marginBottom; + h += paddingBottom; + + h -= 2; // total hack, i just like the way it looks a bit tighter even though technically MenuItem reserves some space to center in normal circumstances } - if(previousChild) - h += previousChild.marginBottom(); + if(offsetY == int.min) + offsetY = parent.parentWindow.lineHeight; auto coord = parent.globalCoordinates(); - dropDown.moveResize(coord.x, coord.y + parent.parentWindow.lineHeight, w, h); + dropDown.moveResize(coord.x + offsetX, coord.y + offsetY, w, h); this.x = 0; this.y = 0; this.width = dropDown.width; @@ -4975,8 +5360,9 @@ class MenuItem : MouseActivatedWidget { override int minHeight() { return Window.lineHeight + 4; } override int minWidth() { return Window.lineHeight * cast(int) label.length + 8; } override int maxWidth() { - if(cast(MenuBar) parent) + if(cast(MenuBar) parent) { return Window.lineHeight / 2 * cast(int) label.length + 8; + } return int.max; } /// @@ -6926,6 +7312,51 @@ void dialog(T)(void delegate(T) onOK, void delegate() onCancel = null) { private static template I(T...) { alias I = T; } + +private string beautify(string name, char space = ' ', bool allLowerCase = false) { + if(name == "id") + return allLowerCase ? name : "ID"; + + char[160] buffer; + int bufferIndex = 0; + bool shouldCap = true; + bool shouldSpace; + bool lastWasCap; + foreach(idx, char ch; name) { + if(bufferIndex == buffer.length) return name; // out of space, just give up, not that important + + if((ch >= 'A' && ch <= 'Z') || ch == '_') { + if(lastWasCap) { + // two caps in a row, don't change. Prolly acronym. + } else { + if(idx) + shouldSpace = true; // new word, add space + } + + lastWasCap = true; + } else { + lastWasCap = false; + } + + if(shouldSpace) { + buffer[bufferIndex++] = space; + if(bufferIndex == buffer.length) return name; // out of space, just give up, not that important + shouldSpace = false; + } + if(shouldCap) { + if(ch >= 'a' && ch <= 'z') + ch -= 32; + shouldCap = false; + } + if(allLowerCase && ch >= 'A' && ch <= 'Z') + ch += 32; + buffer[bufferIndex++] = ch; + } + return buffer[0 .. bufferIndex].idup; +} + + + class AutomaticDialog(T) : Dialog { T t; @@ -6937,6 +7368,7 @@ class AutomaticDialog(T) : Dialog { override int paddingRight() { return Window.lineHeight; } override int paddingLeft() { return Window.lineHeight; } + this(void delegate(T) onOK, void delegate() onCancel) { static if(is(T == class)) t = new T(); @@ -6947,17 +7379,18 @@ class AutomaticDialog(T) : Dialog { foreach(memberName; __traits(allMembers, T)) { alias member = I!(__traits(getMember, t, memberName))[0]; alias type = typeof(member); - static if(is(type == string)) { - auto show = memberName; - // cheap capitalize lol - if(show[0] >= 'a' && show[0] <= 'z') - show = "" ~ cast(char)(show[0] - 32) ~ show[1 .. $]; - auto le = new LabeledLineEdit(show ~ ": ", this); + static if(is(type == bool)) { + auto box = new Checkbox(memberName.beautify, this); + box.addEventListener(EventType.change, (Event ev) { + __traits(getMember, t, memberName) = box.isChecked; + }); + } else static if(is(type == string)) { + auto le = new LabeledLineEdit(memberName.beautify ~ ": ", this); le.addEventListener(EventType.change, (Event ev) { __traits(getMember, t, memberName) = ev.stringValue; }); } else static if(is(type : long)) { - auto le = new LabeledLineEdit(memberName ~ ": ", this); + auto le = new LabeledLineEdit(memberName.beautify ~ ": ", this); le.addEventListener("char", (Event ev) { if((ev.character < '0' || ev.character > '9') && ev.character != '-') ev.preventDefault(); diff --git a/minigui_addons/terminal_emulator_widget.d b/minigui_addons/terminal_emulator_widget.d index 3953e91..c2b692c 100644 --- a/minigui_addons/terminal_emulator_widget.d +++ b/minigui_addons/terminal_emulator_widget.d @@ -6,6 +6,7 @@ +/ module arsd.minigui_addons.terminal_emulator_widget; /// +version(tew_main) unittest { import arsd.minigui; import arsd.minigui_addons.terminal_emulator_widget; @@ -20,6 +21,8 @@ unittest { auto tew = new TerminalEmulatorWidget([`c:\windows\system32\cmd.exe`], window); window.loop(); } + + main(); } import arsd.minigui; @@ -27,6 +30,11 @@ import arsd.minigui; import arsd.terminalemulator; class TerminalEmulatorWidget : Widget { + this(Widget parent) { + terminalEmulator = new TerminalEmulatorInsideWidget(this); + super(parent); + } + this(string[] args, Widget parent) { version(Windows) { import core.sys.windows.windows : HANDLE; @@ -74,7 +82,7 @@ class TerminalEmulatorWidget : Widget { override MouseCursor cursor() { return GenericCursor.Text; } - override void paint(ScreenPainter painter) { + override void paint(WidgetPainter painter) { terminalEmulator.redrawPainter(painter, true); } } @@ -111,10 +119,7 @@ class TerminalEmulatorInsideWidget : TerminalEmulator { } protected override void copyToClipboard(string text) { - static if(UsingSimpledisplayX11) - setPrimarySelection(widget.parentWindow.win, text); - else - setClipboardText(widget.parentWindow.win, text); + setClipboardText(widget.parentWindow.win, text); } protected override void pasteFromClipboard(void delegate(in char[]) dg) { @@ -131,6 +136,23 @@ class TerminalEmulatorInsideWidget : TerminalEmulator { }); } + protected override void copyToPrimary(string text) { + static if(UsingSimpledisplayX11) + setPrimarySelection(widget.parentWindow.win, text); + else + {} + } + protected override void pasteFromPrimary(void delegate(in char[]) dg) { + static if(UsingSimpledisplayX11) + getPrimarySelection(widget.parentWindow.win, dg); + } + + override void requestExit() { + // FIXME + } + + + void resizeImage() { } mixin PtySupport!(resizeImage); @@ -151,31 +173,15 @@ class TerminalEmulatorInsideWidget : TerminalEmulator { bool focused; TerminalEmulatorWidget widget; - OperatingSystemFont font; + + mixin SdpyDraw; private this(TerminalEmulatorWidget widget) { this.widget = widget; - static if(UsingSimpledisplayX11) { - // FIXME: survive reconnects? - fontSize = 14; - font = new OperatingSystemFont("fixed", fontSize, FontWeight.medium); - if(font.isNull) { - // didn't work, it is using a - // fallback, prolly fixed-13 - import std.stdio; writeln("font failed"); - fontWidth = 6; - fontHeight = 13; - } else { - fontWidth = fontSize / 2; - fontHeight = fontSize; - } - } else version(Windows) { - font = new OperatingSystemFont("Courier New", fontSize, FontWeight.medium); - fontHeight = fontSize; - fontWidth = fontSize / 2; - } + fontSize = 14; + loadDefaultFont(); auto desiredWidth = 80; auto desiredHeight = 24; @@ -192,7 +198,8 @@ class TerminalEmulatorInsideWidget : TerminalEmulator { arsd.terminalemulator.MouseEventType.buttonPressed, cast(arsd.terminalemulator.MouseButton) ev.button, (ev.state & ModifierState.shift) ? true : false, - (ev.state & ModifierState.ctrl) ? true : false + (ev.state & ModifierState.ctrl) ? true : false, + (ev.state & ModifierState.alt) ? true : false )) redraw(); }); @@ -205,7 +212,8 @@ class TerminalEmulatorInsideWidget : TerminalEmulator { arsd.terminalemulator.MouseEventType.buttonReleased, cast(arsd.terminalemulator.MouseButton) ev.button, (ev.state & ModifierState.shift) ? true : false, - (ev.state & ModifierState.ctrl) ? true : false + (ev.state & ModifierState.ctrl) ? true : false, + (ev.state & ModifierState.alt) ? true : false )) redraw(); }); @@ -218,7 +226,8 @@ class TerminalEmulatorInsideWidget : TerminalEmulator { arsd.terminalemulator.MouseEventType.motion, cast(arsd.terminalemulator.MouseButton) ev.button, (ev.state & ModifierState.shift) ? true : false, - (ev.state & ModifierState.ctrl) ? true : false + (ev.state & ModifierState.ctrl) ? true : false, + (ev.state & ModifierState.alt) ? true : false )) redraw(); }); @@ -298,21 +307,15 @@ class TerminalEmulatorInsideWidget : TerminalEmulator { } } - int fontWidth; - int fontHeight; - static int fontSize = 14; - enum paddingLeft = 2; - enum paddingTop = 1; - bool clearScreenRequested = true; void redraw(bool forceRedraw = false) { if(widget.parentWindow is null || widget.parentWindow.win is null) return; auto painter = widget.draw(); if(clearScreenRequested) { - auto clearColor = defaultTextAttributes.background; + auto clearColor = defaultBackground; painter.outlineColor = clearColor; painter.fillColor = clearColor; painter.drawRectangle(Point(0, 0), widget.width, widget.height); @@ -323,231 +326,5 @@ class TerminalEmulatorInsideWidget : TerminalEmulator { redrawPainter(painter, forceRedraw); } - bool lastDrawAlternativeScreen; - final arsd.color.Rectangle redrawPainter(T)(T painter, bool forceRedraw) { - arsd.color.Rectangle invalidated; - - // FIXME: could prolly use optimizations - - painter.setFont(font); - - int posx = paddingLeft; - int posy = paddingTop; - - - char[512] bufferText; - bool hasBufferedInfo; - int bufferTextLength; - Color bufferForeground; - Color bufferBackground; - int bufferX = -1; - int bufferY = -1; - bool bufferReverse; - void flushBuffer() { - if(!hasBufferedInfo) { - return; - } - - assert(posx - bufferX - 1 > 0); - - painter.fillColor = bufferReverse ? bufferForeground : bufferBackground; - painter.outlineColor = bufferReverse ? bufferForeground : bufferBackground; - - painter.drawRectangle(Point(bufferX, bufferY), posx - bufferX, fontHeight); - painter.fillColor = Color.transparent; - // Hack for contrast! - if(bufferBackground == Color.black && !bufferReverse) { - // brighter than normal in some cases so i can read it easily - painter.outlineColor = contrastify(bufferForeground); - } else if(bufferBackground == Color.white && !bufferReverse) { - // darker than normal so i can read it - painter.outlineColor = antiContrastify(bufferForeground); - } else if(bufferForeground == bufferBackground) { - // color on itself, I want it visible too - auto hsl = toHsl(bufferForeground, true); - if(hsl[2] < 0.5) - hsl[2] += 0.5; - else - hsl[2] -= 0.5; - painter.outlineColor = fromHsl(hsl[0], hsl[1], hsl[2]); - - } else { - // normal - painter.outlineColor = bufferReverse ? bufferBackground : bufferForeground; - } - - // FIXME: make sure this clips correctly - painter.drawText(Point(bufferX, bufferY), cast(immutable) bufferText[0 .. bufferTextLength]); - - hasBufferedInfo = false; - - bufferReverse = false; - bufferTextLength = 0; - bufferX = -1; - bufferY = -1; - } - - - - int x; - foreach(idx, ref cell; alternateScreenActive ? alternateScreen : normalScreen) { - if(!forceRedraw && !cell.invalidated && lastDrawAlternativeScreen == alternateScreenActive) { - flushBuffer(); - goto skipDrawing; - } - cell.invalidated = false; - version(none) if(bufferX == -1) { // why was this ever here? - bufferX = posx; - bufferY = posy; - } - - { - - invalidated.left = posx < invalidated.left ? posx : invalidated.left; - invalidated.top = posy < invalidated.top ? posy : invalidated.top; - int xmax = posx + fontWidth; - int ymax = posy + fontHeight; - invalidated.right = xmax > invalidated.right ? xmax : invalidated.right; - invalidated.bottom = ymax > invalidated.bottom ? ymax : invalidated.bottom; - - // FIXME: this could be more efficient, simpledisplay could get better graphics context handling - { - - bool reverse = (cell.attributes.inverse != reverseVideo); - if(cell.selected) - reverse = !reverse; - - auto fgc = cell.attributes.foreground; - auto bgc = cell.attributes.background; - - if(!(cell.attributes.foregroundIndex & 0xff00)) { - // this refers to a specific palette entry, which may change, so we should use that - fgc = palette[cell.attributes.foregroundIndex]; - } - if(!(cell.attributes.backgroundIndex & 0xff00)) { - // this refers to a specific palette entry, which may change, so we should use that - bgc = palette[cell.attributes.backgroundIndex]; - } - - if(fgc != bufferForeground || bgc != bufferBackground || reverse != bufferReverse) - flushBuffer(); - bufferReverse = reverse; - bufferBackground = bgc; - bufferForeground = fgc; - } - } - - if(cell.ch != dchar.init) { - char[4] str; - import std.utf; - // now that it is buffered, we do want to draw it this way... - //if(cell.ch != ' ') { // no point wasting time drawing spaces, which are nothing; the bg rectangle already did the important thing - try { - auto stride = encode(str, cell.ch); - if(bufferTextLength + stride > bufferText.length) - flushBuffer(); - bufferText[bufferTextLength .. bufferTextLength + stride] = str[0 .. stride]; - bufferTextLength += stride; - - if(bufferX == -1) { - bufferX = posx; - bufferY = posy; - } - hasBufferedInfo = true; - } catch(Exception e) { - import std.stdio; - writeln(cast(uint) cell.ch, " :: ", e.msg); - } - //} - } else if(cell.nonCharacterData !is null) { - } - - if(cell.attributes.underlined) { - // the posx adjustment is because the buffer assumes it is going - // to be flushed after advancing, but here, we're doing it mid-character - // FIXME: we should just underline the whole thing consecutively, with the buffer - posx += fontWidth; - flushBuffer(); - posx -= fontWidth; - painter.drawLine(Point(posx, posy + fontHeight - 1), Point(posx + fontWidth, posy + fontHeight - 1)); - } - skipDrawing: - - posx += fontWidth; - x++; - if(x == screenWidth) { - flushBuffer(); - x = 0; - posy += fontHeight; - posx = paddingLeft; - } - } - - if(cursorShowing) { - painter.fillColor = cursorColor; - painter.outlineColor = cursorColor; - painter.rasterOp = RasterOp.xor; - - posx = cursorPosition.x * fontWidth + paddingLeft; - posy = cursorPosition.y * fontHeight + paddingTop; - - int cursorWidth = fontWidth; - int cursorHeight = fontHeight; - - final switch(cursorStyle) { - case CursorStyle.block: - painter.drawRectangle(Point(posx, posy), cursorWidth, cursorHeight); - break; - case CursorStyle.underline: - painter.drawRectangle(Point(posx, posy + cursorHeight - 2), cursorWidth, 2); - break; - case CursorStyle.bar: - painter.drawRectangle(Point(posx, posy), 2, cursorHeight); - break; - } - painter.rasterOp = RasterOp.normal; - - // since the cursor draws over the cell, we need to make sure it is redrawn each time too - auto buffer = alternateScreenActive ? (&alternateScreen) : (&normalScreen); - if(cursorX >= 0 && cursorY >= 0 && cursorY < screenHeight && cursorX < screenWidth) { - (*buffer)[cursorY * screenWidth + cursorX].invalidated = true; - } - - invalidated.left = posx < invalidated.left ? posx : invalidated.left; - invalidated.top = posy < invalidated.top ? posy : invalidated.top; - int xmax = posx + fontWidth; - int ymax = xmax + fontHeight; - invalidated.right = xmax > invalidated.right ? xmax : invalidated.right; - invalidated.bottom = ymax > invalidated.bottom ? ymax : invalidated.bottom; - } - - lastDrawAlternativeScreen = alternateScreenActive; - - return invalidated; - } - - - // black bg, make the colors more visible - Color contrastify(Color c) { - if(c == Color(0xcd, 0, 0)) - return Color.fromHsl(0, 1.0, 0.75); - else if(c == Color(0, 0, 0xcd)) - return Color.fromHsl(240, 1.0, 0.75); - else if(c == Color(229, 229, 229)) - return Color(0x99, 0x99, 0x99); - else return c; - } - - // white bg, make them more visible - Color antiContrastify(Color c) { - if(c == Color(0xcd, 0xcd, 0)) - return Color.fromHsl(60, 1.0, 0.25); - else if(c == Color(0, 0xcd, 0xcd)) - return Color.fromHsl(180, 1.0, 0.25); - else if(c == Color(229, 229, 229)) - return Color(0x99, 0x99, 0x99); - else return c; - } - bool debugMode = false; } diff --git a/mvd.d b/mvd.d new file mode 100644 index 0000000..b72d8f4 --- /dev/null +++ b/mvd.d @@ -0,0 +1,93 @@ +/++ + mvd stands for Multiple Virtual Dispatch. It lets you + write functions that take any number of arguments of + objects and match based on the dynamic type of each + of them. + + --- + void foo(Object a, Object b) {} // 1 + void foo(MyClass b, Object b) {} // 2 + void foo(DerivedClass a, MyClass b) {} // 3 + + Object a = new MyClass(); + Object b = new Object(); + + mvd!foo(a, b); // will call overload #2 + --- + + The return values must be compatible; [mvd] will return + the least specialized static type of the return values + (most likely the shared base class type of all return types, + or `void` if there isn't one). + + All non-class/interface types should be compatible among overloads. + Otherwise you are liable to get compile errors. (Or it might work, + that's up to the compiler's discretion.) ++/ +module arsd.mvd; + +import std.traits; + +/// This exists just to make the documentation of [mvd] nicer looking. +alias CommonReturnOfOverloads(alias fn) = CommonType!(staticMap!(ReturnType, __traits(getOverloads, __traits(parent, fn), __traits(identifier, fn)))); + +/// See details on the [arsd.mvd] page. +CommonReturnOfOverloads!fn mvd(alias fn, T...)(T args) { + typeof(return) delegate() bestMatch; + int bestScore; + + ov: foreach(overload; __traits(getOverloads, __traits(parent, fn), __traits(identifier, fn))) { + Parameters!overload pargs; + int score = 0; + foreach(idx, parg; pargs) { + alias t = typeof(parg); + static if(is(t == interface) || is(t == class)) { + pargs[idx] = cast(typeof(parg)) args[idx]; + if(args[idx] !is null && pargs[idx] is null) + continue ov; // failed cast, forget it + else + score += BaseClassesTuple!t.length + 1; + } else + pargs[idx] = args[idx]; + } + if(score == bestScore) + throw new Exception("ambiguous overload selection with args"); // FIXME: show the things + if(score > bestScore) { + bestMatch = () { + static if(is(typeof(return) == void)) + overload(pargs); + else + return overload(pargs); + }; + bestScore = score; + } + } + + if(bestMatch is null) + throw new Exception("no match existed"); + + return bestMatch(); +} + +/// +unittest { + + class MyClass {} + class DerivedClass : MyClass {} + class OtherClass {} + + static struct Wrapper { + static: // this is just a namespace cuz D doesn't allow overloading inside unittest + int foo(Object a, Object b) { return 1; } + int foo(MyClass a, Object b) { return 2; } + int foo(DerivedClass a, MyClass b) { return 3; } + } + + with(Wrapper) { + assert(mvd!foo(new Object, new Object) == 1); + assert(mvd!foo(new MyClass, new DerivedClass) == 2); + assert(mvd!foo(new DerivedClass, new DerivedClass) == 3); + assert(mvd!foo(new OtherClass, new OtherClass) == 1); + assert(mvd!foo(new OtherClass, new MyClass) == 1); + } +} diff --git a/nanovega.d b/nanovega.d index 4620860..988dd25 100644 --- a/nanovega.d +++ b/nanovega.d @@ -14663,7 +14663,7 @@ error: return null; } -/// Create NanoVega OpenGL image from texture id. +/// Using OpenGL texture id creates GLNVGtexture and return its id. /// Group: images public int glCreateImageFromHandleGL2 (NVGContext ctx, GLuint textureId, int w, int h, int imageFlags) nothrow @trusted @nogc { GLNVGcontext* gl = cast(GLNVGcontext*)ctx.internalParams().userPtr; @@ -14680,6 +14680,21 @@ public int glCreateImageFromHandleGL2 (NVGContext ctx, GLuint textureId, int w, return tex.id; } +/// Create NVGImage from OpenGL texture id. +/// Group: images +public NVGImage glCreateImageFromOpenGLTexture(NVGContext ctx, GLuint textureId, int w, int h, int imageFlags) nothrow @trusted @nogc { + auto id = glCreateImageFromHandleGL2(ctx, textureId, w, h, imageFlags); + + NVGImage res; + if (id > 0) { + res.id = id; + version(nanovega_debug_image_manager_rc) { import core.stdc.stdio; printf("createImageRGBA: img=%p; imgid=%d\n", &res, res.id); } + res.ctx = ctx; + ctx.nvg__imageIncRef(res.id, false); // don't increment driver refcount + } + return res; +} + /// Returns OpenGL texture id for NanoVega image. /// Group: images public GLuint glImageHandleGL2 (NVGContext ctx, int image) nothrow @trusted @nogc { diff --git a/png.d b/png.d index d12f9fb..f9c3d00 100644 --- a/png.d +++ b/png.d @@ -685,6 +685,11 @@ void addImageDatastreamToPng(const(ubyte)[] data, PNG* png) { PngHeader h = getHeader(png); + if(h.depth == 0) + throw new Exception("depth of zero makes no sense"); + if(h.width == 0) + throw new Exception("width zero?!!?!?!"); + size_t bytesPerLine; switch(h.type) { case 0: diff --git a/simpledisplay.d b/simpledisplay.d index 185f7df..a1a7457 100644 --- a/simpledisplay.d +++ b/simpledisplay.d @@ -105,6 +105,14 @@ See the examples and topics list below to learn more. + $(WARNING + There should only be one GUI thread per application, + and all windows should be created in it and your + event loop should run there. + + To do otherwise is undefined behavior and has no + cross platform guarantees. + ) $(H2 About this documentation) @@ -1328,7 +1336,7 @@ class SimpleWindow : CapableOfHandlingNativeEvent, CapableOfBeingDrawnUpon { width = the width of the window's client area, in pixels height = the height of the window's client area, in pixels - title = the title of the window (seen in the title bar, taskbar, etc.). You can change it after construction with the [SimpleWindow.title\ property. + title = the title of the window (seen in the title bar, taskbar, etc.). You can change it after construction with the [SimpleWindow.title] property. opengl = [OpenGlOptions] are yes and no. If yes, it creates an OpenGL context on the window. resizable = [Resizability] has three options: $(P `allowResizing`, which allows the window to be resized by the user. The `windowResized` delegate will be called when the size is changed.) @@ -1339,6 +1347,8 @@ class SimpleWindow : CapableOfHandlingNativeEvent, CapableOfBeingDrawnUpon { parent = the parent window, if applicable +/ this(int width = 640, int height = 480, string title = null, OpenGlOptions opengl = OpenGlOptions.no, Resizability resizable = Resizability.automaticallyScaleIfPossible, WindowTypes windowType = WindowTypes.normal, int customizationFlags = WindowFlags.normal, SimpleWindow parent = null) { + claimGuiThread(); + version(sdpy_thread_checks) assert(thisIsGuiThread); this._width = width; this._height = height; this.openglMode = opengl; @@ -2383,7 +2393,7 @@ private: } // wake up event processor - bool eventWakeUp () { + static bool eventWakeUp () { version(X11) { import core.sys.posix.unistd : write; ulong n = 1; @@ -2529,6 +2539,31 @@ private: if (sw is null || sw.closed) continue; sw.processCustomEvents(); } + + // run pending [runInGuiThread] delegates + more: + RunQueueMember* next; + synchronized(runInGuiThreadLock) { + if(runInGuiThreadQueue.length) { + next = runInGuiThreadQueue[0]; + runInGuiThreadQueue = runInGuiThreadQueue[1 .. $]; + } else { + next = null; + } + } + + if(next) { + try { + next.dg(); + next.thrown = null; + } catch(Throwable t) { + next.thrown = t; + } + + next.signal.notify(); + + goto more; + } } // 0: infinite (i.e. no scheduled events in queue) @@ -2704,18 +2739,24 @@ struct EventLoop { return EventLoop(0, null); } + __gshared static Object monitor = new Object(); // deliberate CTFE usage here fyi + /// Construct an application-global event loop for yourself /// See_Also: [SimpleWindow.setEventHandlers] this(long pulseTimeout, void delegate() handlePulse) { - if(impl is null) - impl = new EventLoopImpl(pulseTimeout, handlePulse); - else { - if(pulseTimeout) { - impl.pulseTimeout = pulseTimeout; - impl.handlePulse = handlePulse; + synchronized(monitor) { + if(impl is null) { + claimGuiThread(); + version(sdpy_thread_checks) assert(thisIsGuiThread); + impl = new EventLoopImpl(pulseTimeout, handlePulse); + } else { + if(pulseTimeout) { + impl.pulseTimeout = pulseTimeout; + impl.handlePulse = handlePulse; + } } + impl.refcount++; } - impl.refcount++; } ~this() { @@ -2752,7 +2793,7 @@ struct EventLoop { return impl.signalHandler; } - static EventLoopImpl* impl; + __gshared static EventLoopImpl* impl; } version(linux) @@ -6223,6 +6264,8 @@ class OperatingSystemFont { XFontSet fontset; } else version(Windows) { HFONT font; + int width_; + int height_; } else version(OSXCocoa) { // FIXME } else static assert(0); @@ -6274,6 +6317,15 @@ class OperatingSystemFont { } else version(Windows) { WCharzBuffer buffer = WCharzBuffer(name); font = CreateFont(size, 0, 0, 0, cast(int) weight, italic, 0, 0, 0, 0, 0, 0, 0, buffer.ptr); + + TEXTMETRIC tm; + auto dc = GetDC(null); + SelectObject(dc, font); + GetTextMetrics(dc, &tm); + ReleaseDC(null, dc); + + width_ = tm.tmAveCharWidth; + height_ = tm.tmHeight; } else version(OSXCocoa) { // FIXME } else static assert(0); @@ -6307,6 +6359,26 @@ class OperatingSystemFont { } else static assert(0); } + // Assuming monospace!!!!! + // added March 26, 2020 + int averageWidth() { + version(X11) + return font.max_bounds.width; + else version(Windows) + return width_; + else assert(0); + } + + // Assuming monospace!!!!! + // added March 26, 2020 + int height() { + version(X11) + return font.max_bounds.ascent + font.max_bounds.descent; + else version(Windows) + return height_; + else assert(0); + } + /// FIXME not implemented void loadDefault() { @@ -6320,12 +6392,9 @@ class OperatingSystemFont { /* Metrics */ /+ - GetFontMetrics GetABCWidth GetKerningPairs - XLoadQueryFont - if I do it right, I can size it all here, and match what happens when I draw the full string with the OS functions. @@ -6965,6 +7034,81 @@ void flushGui() { } } +/++ + Runs the given code in the GUI thread when its event loop + is available, blocking until it completes. This allows you + to create and manipulate windows from another thread without + invoking undefined behavior. + + If this is the gui thread, it runs the code immediately. + + If no gui thread exists yet, the current thread is assumed + to be it. Attempting to create windows or run the event loop + in any other thread will cause an assertion failure. + + + $(TIP + Did you know you can use UFCS on delegate literals? + + () { + // code here + }.runInGuiThread; + ) + + History: + Added April 10, 2020 (v7.2.0) ++/ +void runInGuiThread(scope void delegate() dg) @trusted { + claimGuiThread(); + + if(thisIsGuiThread) { + dg(); + return; + } + + import core.sync.semaphore; + static Semaphore sc; + if(sc is null) + sc = new Semaphore(); + + static RunQueueMember* rqm; + if(rqm is null) + rqm = new RunQueueMember; + rqm.dg = cast(typeof(rqm.dg)) dg; + rqm.signal = sc; + rqm.thrown = null; + + synchronized(runInGuiThreadLock) { + runInGuiThreadQueue ~= rqm; + } + + if(!SimpleWindow.eventWakeUp()) + throw new Error("runInGuiThread impossible; eventWakeUp failed"); + + rqm.signal.wait(); + + if(rqm.thrown) + throw rqm.thrown; +} + +private void claimGuiThread() { + import core.atomic; + if(cas(&guiThreadExists, false, true)) + thisIsGuiThread = true; +} + +private struct RunQueueMember { + void delegate() dg; + import core.sync.semaphore; + Semaphore signal; + Throwable thrown; +} + +private __gshared RunQueueMember*[] runInGuiThreadQueue; +private __gshared Object runInGuiThreadLock = new Object; // intentional CTFE +private bool thisIsGuiThread = false; +private shared bool guiThreadExists = false; + /// Used internal to dispatch events to various classes. interface CapableOfHandlingNativeEvent { NativeEventHandler getNativeEventHandler(); @@ -7775,8 +7919,11 @@ version(Windows) { static HFONT defaultGuiFont; void setFont(OperatingSystemFont font) { - if(font && font.font) - SelectObject(hdc, font.font); + if(font && font.font) { + if(SelectObject(hdc, font.font) == HGDI_ERROR) { + // error... how to handle tho? + } + } else if(defaultGuiFont) SelectObject(hdc, defaultGuiFont); } @@ -8341,9 +8488,20 @@ version(Windows) { static int triggerEvents(HWND hwnd, uint msg, WPARAM wParam, LPARAM lParam, int offsetX, int offsetY, SimpleWindow wind) { MouseEvent mouse; - void mouseEvent() { - mouse.x = LOWORD(lParam) + offsetX; - mouse.y = HIWORD(lParam) + offsetY; + void mouseEvent(bool isScreen = false) { + auto x = LOWORD(lParam); + auto y = HIWORD(lParam); + if(isScreen) { + POINT p; + p.x = x; + p.y = y; + ScreenToClient(hwnd, &p); + x = cast(ushort) p.x; + y = cast(ushort) p.y; + } + mouse.x = x + offsetX; + mouse.y = y + offsetY; + wind.mdx(mouse); mouse.modifierState = cast(int) wParam; mouse.window = wind; @@ -8441,7 +8599,7 @@ version(Windows) { case 0x020a /*WM_MOUSEWHEEL*/: mouse.type = cast(MouseEventType) 1; mouse.button = ((HIWORD(wParam) > 120) ? MouseButton.wheelDown : MouseButton.wheelUp); - mouseEvent(); + mouseEvent(true); break; case WM_MOUSEMOVE: mouse.type = cast(MouseEventType) 0; @@ -9655,6 +9813,13 @@ version(X11) { if (w < 1) w = 1; if (h < 1) h = 1; XResizeWindow(display, window, w, h); + + // calling this now to avoid waiting for the server to + // acknowledge the resize; draws without returning to the + // event loop will thus actually work. the server's event + // btw might overrule this and resize it again + recordX11Resize(display, this, w, h); + // FIXME: do we need to set this as the opengl context to do the glViewport change? version(without_opengl) {} else if (openglMode == OpenGlOptions.yes) glViewport(0, 0, w, h); } @@ -10191,6 +10356,71 @@ version(X11) { int mouseDoubleClickTimeout = 350; /// double click timeout. X only, you probably shouldn't change this. + void recordX11Resize(Display* display, SimpleWindow win, int width, int height) { + if(width != win.width || height != win.height) { + win._width = width; + win._height = height; + + if(win.openglMode == OpenGlOptions.no) { + // FIXME: could this be more efficient? + + if (win.bufferw < width || win.bufferh < height) { + //{ import core.stdc.stdio; printf("new buffer; old size: %dx%d; new size: %dx%d\n", win.bufferw, win.bufferh, cast(int)width, cast(int)height); } + // grow the internal buffer to match the window... + auto newPixmap = XCreatePixmap(display, cast(Drawable) win.window, width, height, DefaultDepthOfDisplay(display)); + { + GC xgc = XCreateGC(win.display, cast(Drawable)win.window, 0, null); + XCopyGC(win.display, win.gc, 0xffffffff, xgc); + scope(exit) XFreeGC(win.display, xgc); + XSetClipMask(win.display, xgc, None); + XSetForeground(win.display, xgc, 0); + XFillRectangle(display, cast(Drawable)newPixmap, xgc, 0, 0, width, height); + } + XCopyArea(display, + cast(Drawable) win.buffer, + cast(Drawable) newPixmap, + win.gc, 0, 0, + win.bufferw < width ? win.bufferw : win.width, + win.bufferh < height ? win.bufferh : win.height, + 0, 0); + + XFreePixmap(display, win.buffer); + win.buffer = newPixmap; + win.bufferw = width; + win.bufferh = height; + } + + // clear unused parts of the buffer + if (win.bufferw > width || win.bufferh > height) { + GC xgc = XCreateGC(win.display, cast(Drawable)win.window, 0, null); + XCopyGC(win.display, win.gc, 0xffffffff, xgc); + scope(exit) XFreeGC(win.display, xgc); + XSetClipMask(win.display, xgc, None); + XSetForeground(win.display, xgc, 0); + immutable int maxw = (win.bufferw > width ? win.bufferw : width); + immutable int maxh = (win.bufferh > height ? win.bufferh : height); + XFillRectangle(win.display, cast(Drawable)win.buffer, xgc, width, 0, maxw, maxh); // let X11 do clipping + XFillRectangle(win.display, cast(Drawable)win.buffer, xgc, 0, height, maxw, maxh); // let X11 do clipping + } + + } + + version(without_opengl) {} else + if(win.openglMode == OpenGlOptions.yes && win.resizability == Resizability.automaticallyScaleIfPossible) { + glViewport(0, 0, width, height); + } + + win.fixFixedSize(width, height); //k8: this does nothing on my FluxBox; wtf?! + + if(win.windowResized !is null) { + XUnlockDisplay(display); + scope(exit) XLockDisplay(display); + win.windowResized(width, height); + } + } + } + + /// Platform-specific, you might use it when doing a custom event loop bool doXNextEvent(Display* display) { bool done; @@ -10361,67 +10591,8 @@ version(X11) { auto event = e.xconfigure; if(auto win = event.window in SimpleWindow.nativeMapping) { //version(sdddd) { import std.stdio; writeln(" w=", event.width, "; h=", event.height); } - if(event.width != win.width || event.height != win.height) { - win._width = event.width; - win._height = event.height; - if(win.openglMode == OpenGlOptions.no) { - // FIXME: could this be more efficient? - - if (win.bufferw < event.width || win.bufferh < event.height) { - //{ import core.stdc.stdio; printf("new buffer; old size: %dx%d; new size: %dx%d\n", win.bufferw, win.bufferh, cast(int)event.width, cast(int)event.height); } - // grow the internal buffer to match the window... - auto newPixmap = XCreatePixmap(display, cast(Drawable) event.window, event.width, event.height, DefaultDepthOfDisplay(display)); - { - GC xgc = XCreateGC(win.display, cast(Drawable)win.window, 0, null); - XCopyGC(win.display, win.gc, 0xffffffff, xgc); - scope(exit) XFreeGC(win.display, xgc); - XSetClipMask(win.display, xgc, None); - XSetForeground(win.display, xgc, 0); - XFillRectangle(display, cast(Drawable)newPixmap, xgc, 0, 0, event.width, event.height); - } - XCopyArea(display, - cast(Drawable) (*win).buffer, - cast(Drawable) newPixmap, - (*win).gc, 0, 0, - win.bufferw < event.width ? win.bufferw : win.width, - win.bufferh < event.height ? win.bufferh : win.height, - 0, 0); - - XFreePixmap(display, win.buffer); - win.buffer = newPixmap; - win.bufferw = event.width; - win.bufferh = event.height; - } - - // clear unused parts of the buffer - if (win.bufferw > event.width || win.bufferh > event.height) { - GC xgc = XCreateGC(win.display, cast(Drawable)win.window, 0, null); - XCopyGC(win.display, win.gc, 0xffffffff, xgc); - scope(exit) XFreeGC(win.display, xgc); - XSetClipMask(win.display, xgc, None); - XSetForeground(win.display, xgc, 0); - immutable int maxw = (win.bufferw > event.width ? win.bufferw : event.width); - immutable int maxh = (win.bufferh > event.height ? win.bufferh : event.height); - XFillRectangle(win.display, cast(Drawable)win.buffer, xgc, event.width, 0, maxw, maxh); // let X11 do clipping - XFillRectangle(win.display, cast(Drawable)win.buffer, xgc, 0, event.height, maxw, maxh); // let X11 do clipping - } - - } - - version(without_opengl) {} else - if(win.openglMode == OpenGlOptions.yes && win.resizability == Resizability.automaticallyScaleIfPossible) { - glViewport(0, 0, event.width, event.height); - } - - win.fixFixedSize(event.width, event.height); //k8: this does nothing on my FluxBox; wtf?! - - if(win.windowResized !is null) { - XUnlockDisplay(display); - scope(exit) XLockDisplay(display); - win.windowResized(event.width, event.height); - } - } + recordX11Resize(display, *win, event.width, event.height); } break; case EventType.Expose: diff --git a/svg.d b/svg.d index f6adba0..4e81875 100644 --- a/svg.d +++ b/svg.d @@ -73,11 +73,38 @@ // Allocate memory for image ubyte* img = malloc(w*h*4); // Rasterize - nsvgRasterize(rast, image, 0, 0, 1, img, w, h, w*4); + rasterize(rast, image, 0, 0, 1, img, w, h, w*4); // Delete image.kill(); --- + + To turn a SVG into a png: + --- + import arsd.svg; + import arsd.png; + + void main() { + // Load + NSVG* image = nsvgParseFromFile("test.svg", "px", 96); + + int w = 200; + int h = 200; + + NSVGrasterizer rast = nsvgCreateRasterizer(); + // Allocate memory for image + auto img = new TrueColorImage(w, h); + // Rasterize + rasterize(rast, image, 0, 0, 1, img.imageData.bytes.ptr, w, h, w*4); + + // Delete + image.kill(); + + writePng("test.png", img); + + + } + --- */ module arsd.svg; diff --git a/terminal.d b/terminal.d index a8bbe1b..6a056c0 100644 --- a/terminal.d +++ b/terminal.d @@ -1,4 +1,6 @@ // for optional dependency +// for VT on Windows P s = 1 8 → Report the size of the text area in characters as CSI 8 ; height ; width t +// could be used to have the TE volunteer the size /++ Module for interacting with the user's terminal, including color output, cursor manipulation, and full-featured real-time mouse and keyboard input. Also includes high-level convenience methods, like [Terminal.getline], which gives the user a line editor with history, completion, etc. See the [#examples]. @@ -17,8 +19,8 @@ As a user, if you have to forcibly kill your program and the event doesn't work, there's still ctrl+\ - On Mac Terminal btw, a lot of hacks are needed and mouse support doesn't work. Most functions basically - work now though. + On old Mac Terminal btw, a lot of hacks are needed and mouse support doesn't work. Most functions basically + work now with newer Mac OS versions though. Future_Roadmap: $(LIST @@ -136,11 +138,42 @@ version(demos) unittest { // FIXME: http://msdn.microsoft.com/en-us/library/windows/desktop/ms686016%28v=vs.85%29.aspx -version(Posix) { + +/++ + A function the sigint handler will call (if overridden - which is the + case when [RealTimeConsoleInput] is active on Posix or if you compile with + `TerminalDirectToEmulator` version on any platform at this time) in addition + to the library's default handling, which is to set a flag for the event loop + to inform you. + + Remember, this is called from a signal handler and/or from a separate thread, + so you are not allowed to do much with it and need care when setting TLS variables. + + I suggest you only set a `__gshared bool` flag as many other operations will risk + undefined behavior. + + $(WARNING + This function is never called on the default Windows console + configuration in the current implementation. You can use + `-version=TerminalDirectToEmulator` to guarantee it is called there + too by causing the library to pop up a gui window for your application. + ) + + History: + Added March 30, 2020. Included in release v7.1.0. + ++/ +__gshared void delegate() nothrow @nogc sigIntExtension; + + +version(TerminalDirectToEmulator) { + version=WithEncapsulatedSignals; +} else version(Posix) { enum SIGWINCH = 28; __gshared bool windowSizeChanged = false; __gshared bool interrupted = false; /// you might periodically check this in a long operation and abort if it is set. Remember it is volatile. It is also sent through the input event loop via RealTimeConsoleInput __gshared bool hangedUp = false; /// similar to interrupted. + version=WithSignals; version(with_eventloop) struct SignalFired {} @@ -164,6 +197,9 @@ version(Posix) { send(SignalFired()); catch(Exception) {} } + + if(sigIntExtension) + sigIntExtension(); } extern(C) void hangupSignalHandler(int sigNumber) nothrow { @@ -175,7 +211,6 @@ version(Posix) { catch(Exception) {} } } - } // parts of this were taken from Robik's ConsoleD @@ -185,11 +220,17 @@ version(Posix) { // capabilities. //version = Demo -version(Windows) +version(TerminalDirectToEmulator) { + version=VtEscapeCodes; +} else version(Windows) { + version(VtEscapeCodes) {} // cool version=Win32Console; +} + +version(Windows) + import core.sys.windows.windows; version(Win32Console) { - import core.sys.windows.windows; private { enum RED_BIT = 4; enum GREEN_BIT = 2; @@ -198,27 +239,36 @@ version(Win32Console) { } version(Posix) { + + version=VtEscapeCodes; + import core.sys.posix.termios; import core.sys.posix.unistd; import unix = core.sys.posix.unistd; import core.sys.posix.sys.types; import core.sys.posix.sys.time; import core.stdc.stdio; + + import core.sys.posix.sys.ioctl; +} + +version(VtEscapeCodes) { + + enum UseVtSequences = true; + + version(TerminalDirectToEmulator) { + private { + enum RED_BIT = 1; + enum GREEN_BIT = 2; + enum BLUE_BIT = 4; + } + } else version(Windows) {} else private { enum RED_BIT = 1; enum GREEN_BIT = 2; enum BLUE_BIT = 4; } - version(linux) { - extern(C) int ioctl(int, int, ...); - enum int TIOCGWINSZ = 0x5413; - } else version(OSX) { - import core.stdc.config; - extern(C) int ioctl(int, c_ulong, ...); - enum TIOCGWINSZ = 1074295912; - } else static assert(0, "confirm the value of tiocgwinsz"); - struct winsize { ushort ws_row; ushort ws_col; @@ -293,7 +343,7 @@ vt|vt100|DEC vt100 compatible:\ # Entry for an xterm. Insert mode has been disabled. -vs|xterm|tmux|tmux-256color|screen|screen.xterm|screen.xterm-256color|xterm-color|xterm-256color|vs100|xterm terminal emulator (X Window System):\ +vs|xterm|tmux|tmux-256color|xterm-kitty|screen|screen.xterm|screen.xterm-256color|xterm-color|xterm-256color|vs100|xterm terminal emulator (X Window System):\ :am:bs:mi@:km:co#80:li#55:\ :im@:ei@:\ :cl=\E[H\E[J:\ @@ -373,6 +423,8 @@ an|ansi|ansi-bbs|ANSI terminals (emulators):\ :tc=vt-generic: `; +} else { + enum UseVtSequences = false; } /// A modifier for [Color] @@ -422,6 +474,8 @@ enum ConsoleOutputType { minimalProcessing = 255, /// do the least possible work, skips most construction and desturction tasks. Only use if you know what you're doing here } +alias ConsoleOutputMode = ConsoleOutputType; + /// Some methods will try not to send unnecessary commands to the screen. You can override their judgement using a ForceOption parameter, if present enum ForceOption { automatic = 0, /// automatically decide what to do (best, unless you know for sure it isn't right) @@ -448,6 +502,12 @@ struct Terminal { @disable this(this); private ConsoleOutputType type; + version(TerminalDirectToEmulator) { + private bool windowSizeChanged = false; + private bool interrupted = false; /// you might periodically check this in a long operation and abort if it is set. Remember it is volatile. It is also sent through the input event loop via RealTimeConsoleInput + private bool hangedUp = false; /// similar to interrupted. + } + private TerminalCursor currentCursor_; version(Windows) private CONSOLE_CURSOR_INFO originalCursorInfo; @@ -508,7 +568,21 @@ struct Terminal { returns false. Real time input is similarly impossible if `!stdinIsTerminal`. +/ static bool stdoutIsTerminal() { - version(Posix) { + version(TerminalDirectToEmulator) { + version(Windows) { + // if it is null, it was a gui subsystem exe. But otherwise, it + // might be explicitly redirected and we should respect that for + // compatibility with normal console expectations (even though like + // we COULD pop up a gui and do both, really that isn't the normal + // use of this library so don't wanna go too nuts) + auto hConsole = GetStdHandle(STD_OUTPUT_HANDLE); + return hConsole is null || GetFileType(hConsole) == FILE_TYPE_CHAR; + } else version(Posix) { + // same as normal here since thee is no gui subsystem really + import core.sys.posix.unistd; + return cast(bool) isatty(1); + } else static assert(0); + } else version(Posix) { import core.sys.posix.unistd; return cast(bool) isatty(1); } else version(Win32Console) { @@ -527,7 +601,16 @@ struct Terminal { /// static bool stdinIsTerminal() { - version(Posix) { + version(TerminalDirectToEmulator) { + version(Windows) { + auto hConsole = GetStdHandle(STD_INPUT_HANDLE); + return hConsole is null || GetFileType(hConsole) == FILE_TYPE_CHAR; + } else version(Posix) { + // same as normal here since thee is no gui subsystem really + import core.sys.posix.unistd; + return cast(bool) isatty(0); + } else static assert(0); + } else version(Posix) { import core.sys.posix.unistd; return cast(bool) isatty(0); } else version(Win32Console) { @@ -543,18 +626,21 @@ struct Terminal { void delegate(in void[]) _writeDelegate; // used to override the unix write() system call, set it magically } - version(Posix) { - bool terminalInFamily(string[] terms...) { - import std.process; - import std.string; + bool terminalInFamily(string[] terms...) { + import std.process; + import std.string; + version(TerminalDirectToEmulator) + auto term = "xterm"; + else auto term = environment.get("TERM"); - foreach(t; terms) - if(indexOf(term, t) != -1) - return true; + foreach(t; terms) + if(indexOf(term, t) != -1) + return true; - return false; - } + return false; + } + version(Posix) { // This is a filthy hack because Terminal.app and OS X are garbage who don't // work the way they're advertised. I just have to best-guess hack and hope it // doesn't break anything else. (If you know a better way, let me know!) @@ -566,271 +652,496 @@ struct Terminal { auto term = environment.get("TERM"); return term == "xterm-256color"; } + } else + bool isMacTerminal() { return false; } - static string[string] termcapDatabase; - static void readTermcapFile(bool useBuiltinTermcap = false) { - import std.file; - import std.stdio; - import std.string; + static string[string] termcapDatabase; + static void readTermcapFile(bool useBuiltinTermcap = false) { + import std.file; + import std.stdio; + import std.string; - //if(!exists("/etc/termcap")) - useBuiltinTermcap = true; + //if(!exists("/etc/termcap")) + useBuiltinTermcap = true; - string current; + string current; - void commitCurrentEntry() { - if(current is null) - return; + void commitCurrentEntry() { + if(current is null) + return; - string names = current; - auto idx = indexOf(names, ":"); - if(idx != -1) - names = names[0 .. idx]; + string names = current; + auto idx = indexOf(names, ":"); + if(idx != -1) + names = names[0 .. idx]; - foreach(name; split(names, "|")) - termcapDatabase[name] = current; + foreach(name; split(names, "|")) + termcapDatabase[name] = current; - current = null; - } - - void handleTermcapLine(in char[] line) { - if(line.length == 0) { // blank - commitCurrentEntry(); - return; // continue - } - if(line[0] == '#') // comment - return; // continue - size_t termination = line.length; - if(line[$-1] == '\\') - termination--; // cut off the \\ - current ~= strip(line[0 .. termination]); - // termcap entries must be on one logical line, so if it isn't continued, we know we're done - if(line[$-1] != '\\') - commitCurrentEntry(); - } - - if(useBuiltinTermcap) { - foreach(line; splitLines(builtinTermcap)) { - handleTermcapLine(line); - } - } else { - foreach(line; File("/etc/termcap").byLine()) { - handleTermcapLine(line); - } - } + current = null; } - static string getTermcapDatabase(string terminal) { - import std.string; - - if(termcapDatabase is null) - readTermcapFile(); - - auto data = terminal in termcapDatabase; - if(data is null) - return null; - - auto tc = *data; - auto more = indexOf(tc, ":tc="); - if(more != -1) { - auto tcKey = tc[more + ":tc=".length .. $]; - auto end = indexOf(tcKey, ":"); - if(end != -1) - tcKey = tcKey[0 .. end]; - tc = getTermcapDatabase(tcKey) ~ tc; + void handleTermcapLine(in char[] line) { + if(line.length == 0) { // blank + commitCurrentEntry(); + return; // continue } - - return tc; + if(line[0] == '#') // comment + return; // continue + size_t termination = line.length; + if(line[$-1] == '\\') + termination--; // cut off the \\ + current ~= strip(line[0 .. termination]); + // termcap entries must be on one logical line, so if it isn't continued, we know we're done + if(line[$-1] != '\\') + commitCurrentEntry(); } - string[string] termcap; - void readTermcap() { - import std.process; - import std.string; - import std.array; - - string termcapData = environment.get("TERMCAP"); - if(termcapData.length == 0) { - termcapData = getTermcapDatabase(environment.get("TERM")); + if(useBuiltinTermcap) { + version(VtEscapeCodes) + foreach(line; splitLines(builtinTermcap)) { + handleTermcapLine(line); } - - auto e = replace(termcapData, "\\\n", "\n"); - termcap = null; - - foreach(part; split(e, ":")) { - // FIXME: handle numeric things too - - auto things = split(part, "="); - if(things.length) - termcap[things[0]] = - things.length > 1 ? things[1] : null; + } else { + foreach(line; File("/etc/termcap").byLine()) { + handleTermcapLine(line); } } - - string findSequenceInTermcap(in char[] sequenceIn) { - char[10] sequenceBuffer; - char[] sequence; - if(sequenceIn.length > 0 && sequenceIn[0] == '\033') { - if(!(sequenceIn.length < sequenceBuffer.length - 1)) - return null; - sequenceBuffer[1 .. sequenceIn.length + 1] = sequenceIn[]; - sequenceBuffer[0] = '\\'; - sequenceBuffer[1] = 'E'; - sequence = sequenceBuffer[0 .. sequenceIn.length + 1]; - } else { - sequence = sequenceBuffer[1 .. sequenceIn.length + 1]; - } - - import std.array; - foreach(k, v; termcap) - if(v == sequence) - return k; - return null; - } - - string getTermcap(string key) { - auto k = key in termcap; - if(k !is null) return *k; - return null; - } - - // Looks up a termcap item and tries to execute it. Returns false on failure - bool doTermcap(T...)(string key, T t) { - import std.conv; - auto fs = getTermcap(key); - if(fs is null) - return false; - - int swapNextTwo = 0; - - R getArg(R)(int idx) { - if(swapNextTwo == 2) { - idx ++; - swapNextTwo--; - } else if(swapNextTwo == 1) { - idx --; - swapNextTwo--; - } - - foreach(i, arg; t) { - if(i == idx) - return to!R(arg); - } - assert(0, to!string(idx) ~ " is out of bounds working " ~ fs); - } - - char[256] buffer; - int bufferPos = 0; - - void addChar(char c) { - import std.exception; - enforce(bufferPos < buffer.length); - buffer[bufferPos++] = c; - } - - void addString(in char[] c) { - import std.exception; - enforce(bufferPos + c.length < buffer.length); - buffer[bufferPos .. bufferPos + c.length] = c[]; - bufferPos += c.length; - } - - void addInt(int c, int minSize) { - import std.string; - auto str = format("%0"~(minSize ? to!string(minSize) : "")~"d", c); - addString(str); - } - - bool inPercent; - int argPosition = 0; - int incrementParams = 0; - bool skipNext; - bool nextIsChar; - bool inBackslash; - - foreach(char c; fs) { - if(inBackslash) { - if(c == 'E') - addChar('\033'); - else - addChar(c); - inBackslash = false; - } else if(nextIsChar) { - if(skipNext) - skipNext = false; - else - addChar(cast(char) (c + getArg!int(argPosition) + (incrementParams ? 1 : 0))); - if(incrementParams) incrementParams--; - argPosition++; - inPercent = false; - } else if(inPercent) { - switch(c) { - case '%': - addChar('%'); - inPercent = false; - break; - case '2': - case '3': - case 'd': - if(skipNext) - skipNext = false; - else - addInt(getArg!int(argPosition) + (incrementParams ? 1 : 0), - c == 'd' ? 0 : (c - '0') - ); - if(incrementParams) incrementParams--; - argPosition++; - inPercent = false; - break; - case '.': - if(skipNext) - skipNext = false; - else - addChar(cast(char) (getArg!int(argPosition) + (incrementParams ? 1 : 0))); - if(incrementParams) incrementParams--; - argPosition++; - break; - case '+': - nextIsChar = true; - inPercent = false; - break; - case 'i': - incrementParams = 2; - inPercent = false; - break; - case 's': - skipNext = true; - inPercent = false; - break; - case 'b': - argPosition--; - inPercent = false; - break; - case 'r': - swapNextTwo = 2; - inPercent = false; - break; - // FIXME: there's more - // http://www.gnu.org/software/termutils/manual/termcap-1.3/html_mono/termcap.html - - default: - assert(0, "not supported " ~ c); - } - } else { - if(c == '%') - inPercent = true; - else if(c == '\\') - inBackslash = true; - else - addChar(c); - } - } - - writeStringRaw(buffer[0 .. bufferPos]); - return true; - } } + static string getTermcapDatabase(string terminal) { + import std.string; + + if(termcapDatabase is null) + readTermcapFile(); + + auto data = terminal in termcapDatabase; + if(data is null) + return null; + + auto tc = *data; + auto more = indexOf(tc, ":tc="); + if(more != -1) { + auto tcKey = tc[more + ":tc=".length .. $]; + auto end = indexOf(tcKey, ":"); + if(end != -1) + tcKey = tcKey[0 .. end]; + tc = getTermcapDatabase(tcKey) ~ tc; + } + + return tc; + } + + string[string] termcap; + void readTermcap(string t = null) { + version(TerminalDirectToEmulator) + t = "xterm"; + import std.process; + import std.string; + import std.array; + + string termcapData = environment.get("TERMCAP"); + if(termcapData.length == 0) { + if(t is null) { + t = environment.get("TERM"); + } + + // loosen the check so any xterm variety gets + // the same termcap. odds are this is right + // almost always + if(t.indexOf("xterm") != -1) + t = "xterm"; + + termcapData = getTermcapDatabase(t); + } + + auto e = replace(termcapData, "\\\n", "\n"); + termcap = null; + + foreach(part; split(e, ":")) { + // FIXME: handle numeric things too + + auto things = split(part, "="); + if(things.length) + termcap[things[0]] = + things.length > 1 ? things[1] : null; + } + } + + string findSequenceInTermcap(in char[] sequenceIn) { + char[10] sequenceBuffer; + char[] sequence; + if(sequenceIn.length > 0 && sequenceIn[0] == '\033') { + if(!(sequenceIn.length < sequenceBuffer.length - 1)) + return null; + sequenceBuffer[1 .. sequenceIn.length + 1] = sequenceIn[]; + sequenceBuffer[0] = '\\'; + sequenceBuffer[1] = 'E'; + sequence = sequenceBuffer[0 .. sequenceIn.length + 1]; + } else { + sequence = sequenceBuffer[1 .. sequenceIn.length + 1]; + } + + import std.array; + foreach(k, v; termcap) + if(v == sequence) + return k; + return null; + } + + string getTermcap(string key) { + auto k = key in termcap; + if(k !is null) return *k; + return null; + } + + // Looks up a termcap item and tries to execute it. Returns false on failure + bool doTermcap(T...)(string key, T t) { + import std.conv; + auto fs = getTermcap(key); + if(fs is null) + return false; + + int swapNextTwo = 0; + + R getArg(R)(int idx) { + if(swapNextTwo == 2) { + idx ++; + swapNextTwo--; + } else if(swapNextTwo == 1) { + idx --; + swapNextTwo--; + } + + foreach(i, arg; t) { + if(i == idx) + return to!R(arg); + } + assert(0, to!string(idx) ~ " is out of bounds working " ~ fs); + } + + char[256] buffer; + int bufferPos = 0; + + void addChar(char c) { + import std.exception; + enforce(bufferPos < buffer.length); + buffer[bufferPos++] = c; + } + + void addString(in char[] c) { + import std.exception; + enforce(bufferPos + c.length < buffer.length); + buffer[bufferPos .. bufferPos + c.length] = c[]; + bufferPos += c.length; + } + + void addInt(int c, int minSize) { + import std.string; + auto str = format("%0"~(minSize ? to!string(minSize) : "")~"d", c); + addString(str); + } + + bool inPercent; + int argPosition = 0; + int incrementParams = 0; + bool skipNext; + bool nextIsChar; + bool inBackslash; + + foreach(char c; fs) { + if(inBackslash) { + if(c == 'E') + addChar('\033'); + else + addChar(c); + inBackslash = false; + } else if(nextIsChar) { + if(skipNext) + skipNext = false; + else + addChar(cast(char) (c + getArg!int(argPosition) + (incrementParams ? 1 : 0))); + if(incrementParams) incrementParams--; + argPosition++; + inPercent = false; + } else if(inPercent) { + switch(c) { + case '%': + addChar('%'); + inPercent = false; + break; + case '2': + case '3': + case 'd': + if(skipNext) + skipNext = false; + else + addInt(getArg!int(argPosition) + (incrementParams ? 1 : 0), + c == 'd' ? 0 : (c - '0') + ); + if(incrementParams) incrementParams--; + argPosition++; + inPercent = false; + break; + case '.': + if(skipNext) + skipNext = false; + else + addChar(cast(char) (getArg!int(argPosition) + (incrementParams ? 1 : 0))); + if(incrementParams) incrementParams--; + argPosition++; + break; + case '+': + nextIsChar = true; + inPercent = false; + break; + case 'i': + incrementParams = 2; + inPercent = false; + break; + case 's': + skipNext = true; + inPercent = false; + break; + case 'b': + argPosition--; + inPercent = false; + break; + case 'r': + swapNextTwo = 2; + inPercent = false; + break; + // FIXME: there's more + // http://www.gnu.org/software/termutils/manual/termcap-1.3/html_mono/termcap.html + + default: + assert(0, "not supported " ~ c); + } + } else { + if(c == '%') + inPercent = true; + else if(c == '\\') + inBackslash = true; + else + addChar(c); + } + } + + writeStringRaw(buffer[0 .. bufferPos]); + return true; + } + + uint tcaps; + + bool inlineImagesSupported() { + return (tcaps & TerminalCapabilities.arsdImage) ? true : false; + } + bool clipboardSupported() { + version(Win32Console) return true; + else return (tcaps & TerminalCapabilities.arsdImage) ? true : false; + } + + // only supported on my custom terminal emulator. guarded behind if(inlineImagesSupported) + // though that isn't even 100% accurate but meh + void changeWindowIcon()(string filename) { + if(inlineImagesSupported()) { + import arsd.png; + auto image = readPng(filename); + auto ii = cast(IndexedImage) image; + assert(ii !is null); + + // copy/pasted from my terminalemulator.d + string encodeSmallTextImage(IndexedImage ii) { + char encodeNumeric(int c) { + if(c < 10) + return cast(char)(c + '0'); + if(c < 10 + 26) + return cast(char)(c - 10 + 'a'); + assert(0); + } + + string s; + s ~= encodeNumeric(ii.width); + s ~= encodeNumeric(ii.height); + + foreach(entry; ii.palette) + s ~= entry.toRgbaHexString(); + s ~= "Z"; + + ubyte rleByte; + int rleCount; + + void rleCommit() { + if(rleByte >= 26) + assert(0); // too many colors for us to handle + if(rleCount == 0) + goto finish; + if(rleCount == 1) { + s ~= rleByte + 'a'; + goto finish; + } + + import std.conv; + s ~= to!string(rleCount); + s ~= rleByte + 'a'; + + finish: + rleByte = 0; + rleCount = 0; + } + + foreach(b; ii.data) { + if(b == rleByte) + rleCount++; + else { + rleCommit(); + rleByte = b; + rleCount = 1; + } + } + + rleCommit(); + + return s; + } + + this.writeStringRaw("\033]5000;"~encodeSmallTextImage(ii)~"\007"); + } + } + + // dependent on tcaps... + void displayInlineImage()(ubyte[] imageData) { + if(inlineImagesSupported) { + import std.base64; + + // I might change this protocol later! + enum extensionMagicIdentifier = "ARSD Terminal Emulator binary extension data follows:"; + + this.writeStringRaw("\000"); + this.writeStringRaw(extensionMagicIdentifier); + this.writeStringRaw(Base64.encode(imageData)); + this.writeStringRaw("\000"); + } + } + + void demandUserAttention() { + if(UseVtSequences) { + if(!terminalInFamily("linux")) + writeStringRaw("\033]5001;1\007"); + } + } + + void requestCopyToClipboard(string text) { + if(clipboardSupported) { + import std.base64; + writeStringRaw("\033]52;c;"~Base64.encode(cast(ubyte[])text)~"\007"); + } + } + + void requestCopyToPrimary(string text) { + if(clipboardSupported) { + import std.base64; + writeStringRaw("\033]52;p;"~Base64.encode(cast(ubyte[])text)~"\007"); + } + } + + bool hasDefaultDarkBackground() { + version(Win32Console) { + return !(defaultBackgroundColor & 0xf); + } else version(TerminalDirectToEmulator) { + return integratedTerminalEmulatorConfiguration.defaultBackground.g < 100; + } else { + // FIXME: there is probably a better way to do this + // but like idk how reliable it is. + if(terminalInFamily("linux")) + return true; + else + return false; + } + } + + version(TerminalDirectToEmulator) { + TerminalEmulatorWidget tew; + private __gshared Window mainWindow; + import core.thread; + version(Posix) + ThreadID threadId; + else version(Windows) + HANDLE threadId; + private __gshared Thread guiThread; + + private static class NewTerminalEvent { + Terminal* t; + this(Terminal* t) { + this.t = t; + } + } + } + + version(TerminalDirectToEmulator) + /++ + +/ + this(ConsoleOutputType type) { + this.type = type; + + if(type == ConsoleOutputType.minimalProcessing) { + readTermcap("xterm"); + _suppressDestruction = true; + return; + } + + tcaps = uint.max; // all capabilities + import core.thread; + + version(Posix) + threadId = Thread.getThis.id; + else version(Windows) + threadId = GetCurrentThread(); + + if(guiThread is null) { + guiThread = new Thread( { + auto window = new TerminalEmulatorWindow(&this, null); + mainWindow = window; + mainWindow.win.addEventListener((NewTerminalEvent t) { + auto nw = new TerminalEmulatorWindow(t.t, null); + t.t.tew = nw.tew; + t.t = null; + nw.show(); + }); + tew = window.tew; + //try + window.loop(); + /* + catch(Throwable t) { + import std.stdio; + stdout.writeln(t); + stdout.flush(); + } + */ + }); + guiThread.start(); + guiThread.priority = Thread.PRIORITY_MAX; // gui thread needs responsiveness + } else { + // FIXME: 64 bit builds on linux segfault with multiple terminals + // so that isn't really supported as of yet. + while(cast(shared) mainWindow is null) { + import core.thread; + Thread.sleep(5.msecs); + } + mainWindow.win.postEvent(new NewTerminalEvent(&this)); + } + + // need to wait until it is properly initialized + while(cast(shared) tew is null) { + import core.thread; + Thread.sleep(5.msecs); + } + + initializeVt(); + + } + else + version(Posix) /** * Constructs an instance of Terminal representing the capabilities of @@ -847,15 +1158,20 @@ struct Terminal { this.getSizeOverride = getSizeOverride; this.type = type; - readTermcap(); - if(type == ConsoleOutputType.minimalProcessing) { + readTermcap(); _suppressDestruction = true; return; } - auto info = getTerminalCapabilities(fdIn, fdOut); - //writeln(info); + tcaps = getTerminalCapabilities(fdIn, fdOut); + //writeln(tcaps); + + initializeVt(); + } + + void initializeVt() { + readTermcap(); if(type == ConsoleOutputType.cellular) { doTermcap("ti"); @@ -866,6 +1182,15 @@ struct Terminal { if(terminalInFamily("xterm", "rxvt", "screen", "tmux")) { writeStringRaw("\033[22;0t"); // save window title on a stack (support seems spotty, but it doesn't hurt to have it) } + + } + + // EXPERIMENTAL do not use yet + Terminal alternateScreen() { + assert(this.type != ConsoleOutputType.cellular); + + this.flush(); + return Terminal(ConsoleOutputType.cellular); } version(Win32Console) { @@ -876,54 +1201,59 @@ struct Terminal { version(Win32Console) /// ditto this(ConsoleOutputType type) { - if(type == ConsoleOutputType.cellular) { - hConsole = CreateConsoleScreenBuffer(GENERIC_READ | GENERIC_WRITE, 0, null, CONSOLE_TEXTMODE_BUFFER, null); - if(hConsole == INVALID_HANDLE_VALUE) { - import std.conv; - throw new Exception(to!string(GetLastError())); + if(UseVtSequences) { + hConsole = GetStdHandle(STD_OUTPUT_HANDLE); + initializeVt(); + } else { + if(type == ConsoleOutputType.cellular) { + hConsole = CreateConsoleScreenBuffer(GENERIC_READ | GENERIC_WRITE, 0, null, CONSOLE_TEXTMODE_BUFFER, null); + if(hConsole == INVALID_HANDLE_VALUE) { + import std.conv; + throw new Exception(to!string(GetLastError())); + } + + SetConsoleActiveScreenBuffer(hConsole); + /* + http://msdn.microsoft.com/en-us/library/windows/desktop/ms686125%28v=vs.85%29.aspx + http://msdn.microsoft.com/en-us/library/windows/desktop/ms683193%28v=vs.85%29.aspx + */ + COORD size; + /* + CONSOLE_SCREEN_BUFFER_INFO sbi; + GetConsoleScreenBufferInfo(hConsole, &sbi); + size.X = cast(short) GetSystemMetrics(SM_CXMIN); + size.Y = cast(short) GetSystemMetrics(SM_CYMIN); + */ + + // FIXME: this sucks, maybe i should just revert it. but there shouldn't be scrollbars in cellular mode + //size.X = 80; + //size.Y = 24; + //SetConsoleScreenBufferSize(hConsole, size); + + GetConsoleCursorInfo(hConsole, &originalCursorInfo); + + clear(); + } else { + hConsole = GetStdHandle(STD_OUTPUT_HANDLE); } - SetConsoleActiveScreenBuffer(hConsole); + if(GetConsoleScreenBufferInfo(hConsole, &originalSbi) == 0) + throw new Exception("not a user-interactive terminal"); + + defaultForegroundColor = cast(Color) (originalSbi.wAttributes & 0x0f); + defaultBackgroundColor = cast(Color) ((originalSbi.wAttributes >> 4) & 0x0f); + + // this is unnecessary since I use the W versions of other functions + // and can cause weird font bugs, so I'm commenting unless some other + // need comes up. /* -http://msdn.microsoft.com/en-us/library/windows/desktop/ms686125%28v=vs.85%29.aspx -http://msdn.microsoft.com/en-us/library/windows/desktop/ms683193%28v=vs.85%29.aspx + oldCp = GetConsoleOutputCP(); + SetConsoleOutputCP(65001); // UTF-8 + + oldCpIn = GetConsoleCP(); + SetConsoleCP(65001); // UTF-8 */ - COORD size; - /* - CONSOLE_SCREEN_BUFFER_INFO sbi; - GetConsoleScreenBufferInfo(hConsole, &sbi); - size.X = cast(short) GetSystemMetrics(SM_CXMIN); - size.Y = cast(short) GetSystemMetrics(SM_CYMIN); - */ - - // FIXME: this sucks, maybe i should just revert it. but there shouldn't be scrollbars in cellular mode - //size.X = 80; - //size.Y = 24; - //SetConsoleScreenBufferSize(hConsole, size); - - GetConsoleCursorInfo(hConsole, &originalCursorInfo); - - clear(); - } else { - hConsole = GetStdHandle(STD_OUTPUT_HANDLE); } - - if(GetConsoleScreenBufferInfo(hConsole, &originalSbi) == 0) - throw new Exception("not a user-interactive terminal"); - - defaultForegroundColor = cast(Color) (originalSbi.wAttributes & 0x0f); - defaultBackgroundColor = cast(Color) ((originalSbi.wAttributes >> 4) & 0x0f); - - // this is unnecessary since I use the W versions of other functions - // and can cause weird font bugs, so I'm commenting unless some other - // need comes up. - /* - oldCp = GetConsoleOutputCP(); - SetConsoleOutputCP(65001); // UTF-8 - - oldCpIn = GetConsoleCP(); - SetConsoleCP(65001); // UTF-8 - */ } version(Win32Console) { @@ -936,48 +1266,51 @@ http://msdn.microsoft.com/en-us/library/windows/desktop/ms683193%28v=vs.85%29.as // only use this if you are sure you know what you want, since the terminal is a shared resource you generally really want to reset it to normal when you leave... bool _suppressDestruction; - version(Posix) ~this() { if(_suppressDestruction) { flush(); return; } - if(type == ConsoleOutputType.cellular) { - doTermcap("te"); - } - if(terminalInFamily("xterm", "rxvt", "screen", "tmux")) { - writeStringRaw("\033[23;0t"); // restore window title from the stack - } - cursor = TerminalCursor.DEFAULT; - showCursor(); - reset(); - flush(); - if(lineGetter !is null) - lineGetter.dispose(); - } + if(UseVtSequences) { + if(type == ConsoleOutputType.cellular) { + doTermcap("te"); + } + version(TerminalDirectToEmulator) { + writeln("\n\n"); + setTitle(tew.terminalEmulator.currentTitle ~ " "); + tew.term = null; - version(Windows) - ~this() { - if(_suppressDestruction) { + if(integratedTerminalEmulatorConfiguration.closeOnExit) + tew.parentWindow.close(); + } else + if(terminalInFamily("xterm", "rxvt", "screen", "tmux")) { + writeStringRaw("\033[23;0t"); // restore window title from the stack + } + cursor = TerminalCursor.DEFAULT; + showCursor(); + reset(); flush(); - return; + + if(lineGetter !is null) + lineGetter.dispose(); + } else version(Win32Console) { + flush(); // make sure user data is all flushed before resetting + reset(); + showCursor(); + + if(lineGetter !is null) + lineGetter.dispose(); + + + SetConsoleOutputCP(oldCp); + SetConsoleCP(oldCpIn); + + auto stdo = GetStdHandle(STD_OUTPUT_HANDLE); + SetConsoleActiveScreenBuffer(stdo); + if(hConsole !is stdo) + CloseHandle(hConsole); } - flush(); // make sure user data is all flushed before resetting - reset(); - showCursor(); - - if(lineGetter !is null) - lineGetter.dispose(); - - - SetConsoleOutputCP(oldCp); - SetConsoleCP(oldCpIn); - - auto stdo = GetStdHandle(STD_OUTPUT_HANDLE); - SetConsoleActiveScreenBuffer(stdo); - if(hConsole !is stdo) - CloseHandle(hConsole); } // lazily initialized and preserved between calls to getline for a bit of efficiency (only a bit) @@ -1017,7 +1350,7 @@ http://msdn.microsoft.com/en-us/library/windows/desktop/ms683193%28v=vs.85%29.as _currentForegroundRGB = foreground; _currentBackgroundRGB = background; - version(Windows) { + version(Win32Console) { flush(); ushort setTob = cast(ushort) approximate16Color(background); ushort setTof = cast(ushort) approximate16Color(foreground); @@ -1032,6 +1365,7 @@ http://msdn.microsoft.com/en-us/library/windows/desktop/ms683193%28v=vs.85%29.as // fallback to 16 color for term that i know don't take it well import std.process; import std.string; + version(TerminalDirectToEmulator) {} else if(environment.get("TERM") == "rxvt" || environment.get("TERM") == "linux") { // not likely supported, use 16 color fallback auto setTof = approximate16Color(foreground); @@ -1064,7 +1398,7 @@ http://msdn.microsoft.com/en-us/library/windows/desktop/ms683193%28v=vs.85%29.as /// Changes the current color. See enum Color for the values. void color(int foreground, int background, ForceOption force = ForceOption.automatic, bool reverseVideo = false) { if(force != ForceOption.neverSend) { - version(Windows) { + version(Win32Console) { // assuming a dark background on windows, so LowContrast == dark which means the bit is NOT set on hardware /* foreground ^= LowContrast; @@ -1138,11 +1472,74 @@ http://msdn.microsoft.com/en-us/library/windows/desktop/ms683193%28v=vs.85%29.as private bool _underlined = false; + /++ + Outputs a hyperlink to my custom terminal (v0.0.7 or later) or to version + `TerminalDirectToEmulator`. The way it works is a bit strange... + + + If using a terminal that supports it, it outputs the given text with the + given identifier attached (one bit of identifier per grapheme of text!). When + the user clicks on it, it will send a [LinkEvent] with the text and the identifier + for you to respond, if in real-time input mode, or a simple paste event with the + text if not (you will not be able to distinguish this from a user pasting the + same text). + + If the user's terminal does not support my feature, it writes plain text instead. + + It is important that you make sure your program still works even if the hyperlinks + never work - ideally, make them out of text the user can type manually or copy/paste + into your command line somehow too. + + Hyperlinks may not work correctly after your program exits or if you are capturing + mouse input (the user will have to hold shift in that case). It is really designed + for linear mode with direct to emulator mode. If you are using cellular mode with + full input capturing, you should manage the clicks yourself. + + Similarly, if it horizontally scrolls off the screen, it can be corrupted since it + packs your text and identifier into free bits in the screen buffer itself. I may be + able to fix that later. + + Params: + text = text displayed in the terminal + identifier = an additional number attached to the text and returned to you in a [LinkEvent] + autoStyle = set to `false` to suppress the automatic color and underlining of the text. + + Bugs: + there's no keyboard interaction with it at all right now. i might make the terminal + emulator offer the ids or something through a hold ctrl or something interface. idk. + or tap ctrl twice to turn that on. + + History: + Added March 18, 2020 + +/ + void hyperlink(string text, ushort identifier = 0, bool autoStyle = true) { + if((tcaps & TerminalCapabilities.arsdHyperlinks)) { + bool previouslyUnderlined = _underlined; + int fg = _currentForeground, bg = _currentBackground; + if(autoStyle) { + color(Color.blue, Color.white); + underline = true; + } + + import std.conv; + writeStringRaw("\033[?" ~ to!string(65536 + identifier) ~ "h"); + write(text); + writeStringRaw("\033[?65536l"); + + if(autoStyle) { + underline = previouslyUnderlined; + color(fg, bg); + } + } else { + write(text); // graceful degrade + } + } + /// Note: the Windows console does not support underlining void underline(bool set, ForceOption force = ForceOption.automatic) { if(set == _underlined && force != ForceOption.alwaysSend) return; - version(Posix) { + if(UseVtSequences) { if(set) writeStringRaw("\033[4m"); else @@ -1186,14 +1583,14 @@ http://msdn.microsoft.com/en-us/library/windows/desktop/ms683193%28v=vs.85%29.as void moveTo(int x, int y, ForceOption force = ForceOption.automatic) { if(force != ForceOption.neverSend && (force == ForceOption.alwaysSend || x != _cursorX || y != _cursorY)) { executeAutoHideCursor(); - version(Posix) { + if(UseVtSequences) { doTermcap("cm", y, x); } else version(Win32Console) { flush(); // if we don't do this now, the buffering can screw up the position COORD coord = {cast(short) x, cast(short) y}; SetConsoleCursorPosition(hConsole, coord); - } else static assert(0); + } } _cursorX = x; @@ -1202,9 +1599,9 @@ http://msdn.microsoft.com/en-us/library/windows/desktop/ms683193%28v=vs.85%29.as /// shows the cursor void showCursor() { - version(Posix) + if(UseVtSequences) doTermcap("ve"); - else { + else version(Win32Console) { CONSOLE_CURSOR_INFO info; GetConsoleCursorInfo(hConsole, &info); info.bVisible = true; @@ -1214,9 +1611,9 @@ http://msdn.microsoft.com/en-us/library/windows/desktop/ms683193%28v=vs.85%29.as /// hides the cursor void hideCursor() { - version(Posix) { + if(UseVtSequences) { doTermcap("vi"); - } else { + } else version(Win32Console) { CONSOLE_CURSOR_INFO info; GetConsoleCursorInfo(hConsole, &info); info.bVisible = false; @@ -1238,7 +1635,7 @@ http://msdn.microsoft.com/en-us/library/windows/desktop/ms683193%28v=vs.85%29.as if(autoHidingCursor) { version(Win32Console) hideCursor(); - else version(Posix) { + else if(UseVtSequences) { // prepend the hide cursor command so it is the first thing flushed writeBuffer = "\033[?25l" ~ writeBuffer; } @@ -1294,7 +1691,10 @@ http://msdn.microsoft.com/en-us/library/windows/desktop/ms683193%28v=vs.85%29.as if(writeBuffer.length == 0) return; - version(Posix) { + version(TerminalDirectToEmulator) { + tew.sendRawInput(cast(ubyte[]) writeBuffer); + writeBuffer = null; + } else version(Posix) { if(_writeDelegate !is null) { _writeDelegate(writeBuffer); } else { @@ -1323,7 +1723,9 @@ http://msdn.microsoft.com/en-us/library/windows/desktop/ms683193%28v=vs.85%29.as } int[] getSize() { - version(Win32Console) { + version(TerminalDirectToEmulator) { + return [tew.terminalEmulator.width, tew.terminalEmulator.height]; + } else version(Windows) { CONSOLE_SCREEN_BUFFER_INFO info; GetConsoleScreenBufferInfo( hConsole, &info ); @@ -1422,10 +1824,13 @@ http://msdn.microsoft.com/en-us/library/windows/desktop/ms683193%28v=vs.85%29.as } +/ - void writePrintableString(in char[] s, ForceOption force = ForceOption.automatic) { + void writePrintableString(const(char)[] s, ForceOption force = ForceOption.automatic) { // an escape character is going to mess things up. Actually any non-printable character could, but meh // assert(s.indexOf("\033") == -1); + if(s.length == 0) + return; + // tracking cursor position // FIXME: by grapheme? foreach(dchar ch; s) { @@ -1461,7 +1866,26 @@ http://msdn.microsoft.com/en-us/library/windows/desktop/ms683193%28v=vs.85%29.as +/ } - writeStringRaw(s); + version(TerminalDirectToEmulator) { + // this breaks up extremely long output a little as an aid to the + // gui thread; by breaking it up, it helps to avoid monopolizing the + // event loop. Easier to do here than in the thread itself because + // this one doesn't have escape sequences to break up so it avoids work. + while(s.length) { + auto len = s.length; + if(len > 1024 * 32) { + len = 1024 * 32; + // get to the start of a utf-8 sequence. kidna sorta. + while(len && (s[len] & 0x1000_0000)) + len--; + } + auto next = s[0 .. len]; + s = s[len .. $]; + writeStringRaw(next); + } + } else { + writeStringRaw(s); + } } /* private */ bool _wrapAround = true; @@ -1472,17 +1896,14 @@ http://msdn.microsoft.com/en-us/library/windows/desktop/ms683193%28v=vs.85%29.as // you really, really shouldn't use this unless you know what you are doing /*private*/ void writeStringRaw(in char[] s) { - // FIXME: make sure all the data is sent, check for errors - version(Posix) { - writeBuffer ~= s; // buffer it to do everything at once in flush() calls - } else version(Win32Console) { - writeBuffer ~= s; - } else static assert(0); + writeBuffer ~= s; // buffer it to do everything at once in flush() calls + if(writeBuffer.length > 1024 * 32) + flush(); } /// Clears the screen. void clear() { - version(Posix) { + if(UseVtSequences) { doTermcap("cl"); } else version(Win32Console) { // http://support.microsoft.com/kb/99261 @@ -1531,6 +1952,44 @@ http://msdn.microsoft.com/en-us/library/windows/desktop/ms683193%28v=vs.85%29.as } +/++ + Removes terminal color, bold, etc. sequences from a string, + making it plain text suitable for output to a normal .txt + file. ++/ +inout(char)[] removeTerminalGraphicsSequences(inout(char)[] s) { + import std.string; + + auto at = s.indexOf("\033["); + if(at == -1) + return s; + + inout(char)[] ret; + + do { + ret ~= s[0 .. at]; + s = s[at + 2 .. $]; + while(s.length && !((s[0] >= 'a' && s[0] <= 'z') || s[0] >= 'A' && s[0] <= 'Z')) { + s = s[1 .. $]; + } + if(s.length) + s = s[1 .. $]; // skip the terminator + at = s.indexOf("\033["); + } while(at != -1); + + ret ~= s; + + return ret; +} + +unittest { + assert("foo".removeTerminalGraphicsSequences == "foo"); + assert("\033[34mfoo".removeTerminalGraphicsSequences == "foo"); + assert("\033[34mfoo\033[39m".removeTerminalGraphicsSequences == "foo"); + assert("\033[34m\033[45mfoo\033[39mbar\033[49m".removeTerminalGraphicsSequences == "foobar"); +} + + /+ struct ConsoleBuffer { int cursorX; @@ -1562,6 +2021,66 @@ struct RealTimeConsoleInput { @disable this(); @disable this(this); + /++ + Requests the system to send paste data as a [PasteEvent] to this stream, if possible. + + See_Also: + [Terminal.requestCopyToPrimary] + [Terminal.requestCopyToClipboard] + [Terminal.clipboardSupported] + + History: + Added February 17, 2020. + + It was in Terminal briefly during an undocumented period, but it had to be moved here to have the context needed to send the real time paste event. + +/ + void requestPasteFromClipboard() { + version(Win32Console) { + HWND hwndOwner = null; + if(OpenClipboard(hwndOwner) == 0) + throw new Exception("OpenClipboard"); + scope(exit) + CloseClipboard(); + if(auto dataHandle = GetClipboardData(CF_UNICODETEXT)) { + + if(auto data = cast(wchar*) GlobalLock(dataHandle)) { + scope(exit) + GlobalUnlock(dataHandle); + + int len = 0; + auto d = data; + while(*d) { + d++; + len++; + } + string s; + s.reserve(len); + foreach(idx, dchar ch; data[0 .. len]) { + // CR/LF -> LF + if(ch == '\r' && idx + 1 < len && data[idx + 1] == '\n') + continue; + s ~= ch; + } + + injectEvent(InputEvent(PasteEvent(s), terminal), InjectionPosition.tail); + } + } + } else + if(terminal.clipboardSupported) { + if(UseVtSequences) + terminal.writeStringRaw("\033]52;c;?\007"); + } + } + + /// ditto + void requestPasteFromPrimary() { + if(terminal.clipboardSupported) { + if(UseVtSequences) + terminal.writeStringRaw("\033]52;p;?\007"); + } + } + + version(Posix) { private int fdOut; private int fdIn; @@ -1596,7 +2115,7 @@ struct RealTimeConsoleInput { GetConsoleMode(inputHandle, &oldInput); DWORD mode = 0; - mode |= ENABLE_PROCESSED_INPUT /* 0x01 */; // this gives Ctrl+C which we probably want to be similar to linux + //mode |= ENABLE_PROCESSED_INPUT /* 0x01 */; // this gives Ctrl+C and automatic paste... which we probably want to be similar to linux //if(flags & ConsoleInputFlags.size) mode |= ENABLE_WINDOW_INPUT /* 0208 */; // gives size etc if(flags & ConsoleInputFlags.echo) @@ -1619,7 +2138,9 @@ struct RealTimeConsoleInput { destructor ~= { SetConsoleMode(terminal.hConsole, oldOutput); }; } - version(Posix) { + version(TerminalDirectToEmulator) { + terminal.tew.terminalEmulator.echo = (flags & ConsoleInputFlags.echo) ? true : false; + } else version(Posix) { this.fdIn = terminal.fdIn; this.fdOut = terminal.fdOut; @@ -1666,9 +2187,9 @@ struct RealTimeConsoleInput { n.sa_flags = 0; sigaction(SIGHUP, &n, &oldHupIntr); } + } - - + if(UseVtSequences) { if(flags & ConsoleInputFlags.mouse) { // basic button press+release notification @@ -1698,6 +2219,11 @@ struct RealTimeConsoleInput { } } + if(terminal.tcaps & TerminalCapabilities.arsdHyperlinks) { + terminal.writeStringRaw("\033[?3004h"); // bracketed link mode + destructor ~= { terminal.writeStringRaw("\033[?3004l"); }; + } + // try to ensure the terminal is in UTF-8 mode if(terminal.terminalInFamily("xterm", "screen", "linux", "tmux") && !terminal.isMacTerminal()) { terminal.writeStringRaw("\033%G"); @@ -1785,10 +2311,13 @@ struct RealTimeConsoleInput { return; // the delegate thing doesn't actually work for this... for some reason + + version(TerminalDirectToEmulator) { } else version(Posix) if(fdIn != -1) tcsetattr(fdIn, TCSANOW, &old); + version(TerminalDirectToEmulator) { } else version(Posix) { if(flags & ConsoleInputFlags.size) { // restoration @@ -1825,7 +2354,10 @@ struct RealTimeConsoleInput { bool timedCheckForInput(int milliseconds) { if(inputQueue.length || timedCheckForInput_bypassingBuffer(milliseconds)) return true; - version(Posix) + version(WithEncapsulatedSignals) + if(terminal.interrupted || terminal.windowSizeChanged || terminal.hangedUp) + return true; + version(WithSignals) if(interrupted || windowSizeChanged || hangedUp) return true; return false; @@ -1836,8 +2368,19 @@ struct RealTimeConsoleInput { } bool timedCheckForInput_bypassingBuffer(int milliseconds) { - version(Win32Console) { - auto response = WaitForSingleObject(terminal.hConsole, milliseconds); + version(TerminalDirectToEmulator) { + import core.time; + if(terminal.tew.terminalEmulator.pendingForApplication.length) + return true; + if(terminal.tew.terminalEmulator.outgoingSignal.wait(milliseconds.msecs)) + // it was notified, but it could be left over from stuff we + // already processed... so gonna check the blocking conditions here too + // (FIXME: this sucks and is surely a race condition of pain) + return terminal.tew.terminalEmulator.pendingForApplication.length || terminal.interrupted || terminal.windowSizeChanged || terminal.hangedUp; + else + return false; + } else version(Win32Console) { + auto response = WaitForSingleObject(inputHandle, milliseconds); if(response == 0) return true; // the object is ready return false; @@ -1853,9 +2396,20 @@ struct RealTimeConsoleInput { FD_ZERO(&fs); FD_SET(fdIn, &fs); - if(select(fdIn + 1, &fs, null, null, &tv) == -1) { + int tries = 0; + try_again: + auto ret = select(fdIn + 1, &fs, null, null, &tv); + if(ret == -1) { + import core.stdc.errno; + if(errno == EINTR) { + tries++; + if(tries < 3) + goto try_again; + } return false; } + if(ret == 0) + return false; return FD_ISSET(fdIn, &fs); } @@ -1896,40 +2450,65 @@ struct RealTimeConsoleInput { //char[128] inputBuffer; //int inputBufferPosition; - version(Posix) int nextRaw(bool interruptable = false) { - if(fdIn == -1) - return 0; + version(TerminalDirectToEmulator) { + moar: + //if(interruptable && inputQueue.length) + //return -1; + if(terminal.tew.terminalEmulator.pendingForApplication.length == 0) + terminal.tew.terminalEmulator.outgoingSignal.wait(); + synchronized(terminal.tew.terminalEmulator) { + if(terminal.tew.terminalEmulator.pendingForApplication.length == 0) { + if(interruptable) + return -1; + else + goto moar; + } + auto a = terminal.tew.terminalEmulator.pendingForApplication[0]; + terminal.tew.terminalEmulator.pendingForApplication = terminal.tew.terminalEmulator.pendingForApplication[1 .. $]; + return a; + } + } else version(Posix) { + if(fdIn == -1) + return 0; - char[1] buf; - try_again: - auto ret = read(fdIn, buf.ptr, buf.length); - if(ret == 0) - return 0; // input closed - if(ret == -1) { - import core.stdc.errno; - if(errno == EINTR) - // interrupted by signal call, quite possibly resize or ctrl+c which we want to check for in the event loop - if(interruptable) - return -1; + char[1] buf; + try_again: + auto ret = read(fdIn, buf.ptr, buf.length); + if(ret == 0) + return 0; // input closed + if(ret == -1) { + import core.stdc.errno; + if(errno == EINTR) + // interrupted by signal call, quite possibly resize or ctrl+c which we want to check for in the event loop + if(interruptable) + return -1; + else + goto try_again; else - goto try_again; + throw new Exception("read failed"); + } + + //terminal.writef("RAW READ: %d\n", buf[0]); + + if(ret == 1) + return inputPrefilter ? inputPrefilter(buf[0]) : buf[0]; else - throw new Exception("read failed"); + assert(0); // read too much, should be impossible + } else version(Windows) { + char[1] buf; + DWORD d; + import std.conv; + if(!ReadFile(inputHandle, buf.ptr, cast(int) buf.length, &d, null)) + throw new Exception("ReadFile " ~ to!string(GetLastError())); + return buf[0]; } - - //terminal.writef("RAW READ: %d\n", buf[0]); - - if(ret == 1) - return inputPrefilter ? inputPrefilter(buf[0]) : buf[0]; - else - assert(0); // read too much, should be impossible } version(Posix) int delegate(char) inputPrefilter; - version(Posix) + // for VT dchar nextChar(int starting) { if(starting <= 127) return cast(dchar) starting; @@ -1959,8 +2538,10 @@ struct RealTimeConsoleInput { auto oldWidth = terminal.width; auto oldHeight = terminal.height; terminal.updateSize(); - version(Posix) - windowSizeChanged = false; + version(WithSignals) + windowSizeChanged = false; + version(WithEncapsulatedSignals) + terminal.windowSizeChanged = false; return InputEvent(SizeChangedEvent(oldWidth, oldHeight, terminal.width, terminal.height), terminal); } @@ -1978,30 +2559,46 @@ struct RealTimeConsoleInput { /// require the module arsd.eventloop (Linux only at this point) InputEvent nextEvent() { terminal.flush(); + + wait_for_more: + version(WithSignals) { + if(interrupted) { + interrupted = false; + return InputEvent(UserInterruptionEvent(), terminal); + } + + if(hangedUp) { + hangedUp = false; + return InputEvent(HangupEvent(), terminal); + } + + if(windowSizeChanged) { + return checkWindowSizeChanged(); + } + } + + version(WithEncapsulatedSignals) { + if(terminal.interrupted) { + terminal.interrupted = false; + return InputEvent(UserInterruptionEvent(), terminal); + } + + if(terminal.hangedUp) { + terminal.hangedUp = false; + return InputEvent(HangupEvent(), terminal); + } + + if(terminal.windowSizeChanged) { + return checkWindowSizeChanged(); + } + } + if(inputQueue.length) { auto e = inputQueue[0]; inputQueue = inputQueue[1 .. $]; return e; } - wait_for_more: - version(Posix) - if(interrupted) { - interrupted = false; - return InputEvent(UserInterruptionEvent(), terminal); - } - - version(Posix) - if(hangedUp) { - hangedUp = false; - return InputEvent(HangupEvent(), terminal); - } - - version(Posix) - if(windowSizeChanged) { - return checkWindowSizeChanged(); - } - auto more = readNextEvents(); if(!more.length) goto wait_for_more; // i used to do a loop (readNextEvents can read something, but it might be discarded by the input filter) but now it goto's above because readNextEvents might be interrupted by a SIGWINCH aka size event so we want to check that at least @@ -2033,8 +2630,17 @@ struct RealTimeConsoleInput { InputEvent[] inputQueue; - version(Win32Console) InputEvent[] readNextEvents() { + if(UseVtSequences) + return readNextEventsVt(); + else version(Win32Console) + return readNextEventsWin32(); + else + assert(0); + } + + version(Win32Console) + InputEvent[] readNextEventsWin32() { terminal.flush(); // make sure all output is sent out before waiting for anything INPUT_RECORD[32] buffer; @@ -2076,12 +2682,32 @@ struct RealTimeConsoleInput { if(ev.UnicodeChar) { // new style event goes first - ke.which = cast(dchar) cast(wchar) ev.UnicodeChar; - newEvents ~= InputEvent(ke, terminal); - // old style event then follows as the fallback - e.character = cast(dchar) cast(wchar) ev.UnicodeChar; - newEvents ~= InputEvent(e, terminal); + if(ev.UnicodeChar == 3) { + // handling this internally for linux compat too + newEvents ~= InputEvent(UserInterruptionEvent(), terminal); + } else if(ev.UnicodeChar == '\r') { + // translating \r to \n for same result as linux... + ke.which = cast(dchar) cast(wchar) '\n'; + newEvents ~= InputEvent(ke, terminal); + + // old style event then follows as the fallback + e.character = cast(dchar) cast(wchar) '\n'; + newEvents ~= InputEvent(e, terminal); + } else if(ev.wVirtualKeyCode == 0x1b) { + ke.which = cast(KeyboardEvent.Key) (ev.wVirtualKeyCode + 0xF0000); + newEvents ~= InputEvent(ke, terminal); + + ne.key = cast(NonCharacterKeyEvent.Key) ev.wVirtualKeyCode; + newEvents ~= InputEvent(ne, terminal); + } else { + ke.which = cast(dchar) cast(wchar) ev.UnicodeChar; + newEvents ~= InputEvent(ke, terminal); + + // old style event then follows as the fallback + e.character = cast(dchar) cast(wchar) ev.UnicodeChar; + newEvents ~= InputEvent(e, terminal); + } } else { // old style event ne.key = cast(NonCharacterKeyEvent.Key) ev.wVirtualKeyCode; @@ -2159,8 +2785,8 @@ struct RealTimeConsoleInput { return newEvents; } - version(Posix) - InputEvent[] readNextEvents() { + // for UseVtSequences.... + InputEvent[] readNextEventsVt() { terminal.flush(); // make sure all output is sent out before we try to get input // we want to starve the read, especially if we're called from an edge-triggered @@ -2184,8 +2810,8 @@ struct RealTimeConsoleInput { } // The helper reads just one actual event from the pipe... - version(Posix) - InputEvent[] readNextEventsHelper() { + // for UseVtSequences.... + InputEvent[] readNextEventsHelper(int remainingFromLastTime = int.max) { InputEvent[] charPressAndRelease(dchar character) { if((flags & ConsoleInputFlags.releasedKeys)) return [ @@ -2364,6 +2990,41 @@ struct RealTimeConsoleInput { } } return [InputEvent(PasteEvent(data), terminal)]; + case "\033[220~": + // bracketed hyperlink begin (arsd extension) + + string data; + for(;;) { + auto n = nextRaw(); + if(n == '\033') { + n = nextRaw(); + if(n == '[') { + auto esc = readEscapeSequence(sequenceBuffer); + if(esc == "\033[221~") { + // complete! + break; + } else { + // was something else apparently, but it is pasted, so keep it + data ~= esc; + } + } else { + data ~= '\033'; + data ~= cast(char) n; + } + } else { + data ~= cast(char) n; + } + } + + import std.string, std.conv; + auto idx = data.indexOf(";"); + auto id = data[0 .. idx].to!ushort; + data = data[idx + 1 .. $]; + idx = data.indexOf(";"); + auto cmd = data[0 .. idx].to!ushort; + data = data[idx + 1 .. $]; + + return [InputEvent(LinkEvent(data, id, cmd), terminal)]; case "\033[M": // mouse event auto buttonCode = nextRaw() - 32; @@ -2540,13 +3201,13 @@ struct RealTimeConsoleInput { return null; } - auto c = nextRaw(true); + auto c = remainingFromLastTime == int.max ? nextRaw(true) : remainingFromLastTime; if(c == -1) return null; // interrupted; give back nothing so the other level can recheck signal flags if(c == 0) return [InputEvent(EndOfFileEvent(), terminal)]; if(c == '\033') { - if(timedCheckForInput(50)) { + if(timedCheckForInput_bypassingBuffer(50)) { // escape sequence c = nextRaw(); if(c == '[') { // CSI, ends on anything >= 'A' @@ -2568,6 +3229,9 @@ struct RealTimeConsoleInput { } else { return translateTermcapName(cap); } + } else if(c == '\033') { + // could be escape followed by an escape sequence! + return keyPressAndRelease(NonCharacterKeyEvent.Key.escape) ~ readNextEventsHelper(c); } else { // I don't know, probably unsupported terminal or just quick user input or something return keyPressAndRelease(NonCharacterKeyEvent.Key.escape) ~ charPressAndRelease(nextChar(c)); @@ -2590,6 +3254,8 @@ struct RealTimeConsoleInput { struct KeyboardEvent { bool pressed; /// dchar which; /// + alias key = which; /// I often use this when porting old to new so i took it + alias character = which; /// I often use this when porting old to new so i took it uint modifierState; /// /// @@ -2694,6 +3360,22 @@ struct PasteEvent { string pastedText; /// . } +/++ + Indicates a hyperlink was clicked in my custom terminal emulator + or with version `TerminalDirectToEmulator`. + + You can simply ignore this event in a `final switch` if you aren't + using the feature. + + History: + Added March 18, 2020 ++/ +struct LinkEvent { + string text; /// + ushort identifier; /// + ushort command; /// set by the terminal to indicate how it was clicked. values tbd +} + /// . struct MouseEvent { // these match simpledisplay.d numerically as well @@ -2791,6 +3473,7 @@ struct InputEvent { CharacterEvent, /// Do not use this in new programs, use KeyboardEvent instead NonCharacterKeyEvent, /// Do not use this in new programs, use KeyboardEvent instead PasteEvent, /// The user pasted some text. Not always available, the pasted text might come as a series of character events instead. + LinkEvent, /// User clicked a hyperlink you created. Simply ignore if you are not using that feature. MouseEvent, /// only sent if you subscribed to mouse events SizeChangedEvent, /// only sent if you subscribed to size events UserInterruptionEvent, /// the user hit ctrl+c @@ -2799,6 +3482,11 @@ struct InputEvent { CustomEvent /// . } + /// If this event is deprecated, you should filter it out in new programs + bool isDeprecated() { + return type == Type.CharacterEvent || type == Type.NonCharacterKeyEvent; + } + /// . @property Type type() { return t; } @@ -2832,6 +3520,8 @@ struct InputEvent { return nonCharacterKeyEvent; else static if(T == Type.PasteEvent) return pasteEvent; + else static if(T == Type.LinkEvent) + return linkEvent; else static if(T == Type.MouseEvent) return mouseEvent; else static if(T == Type.SizeChangedEvent) @@ -2870,6 +3560,10 @@ struct InputEvent { t = Type.PasteEvent; pasteEvent = c; } + this(LinkEvent c, Terminal* p) { + t = Type.LinkEvent; + linkEvent = c; + } this(MouseEvent c, Terminal* p) { t = Type.MouseEvent; mouseEvent = c; @@ -2904,6 +3598,7 @@ struct InputEvent { UserInterruptionEvent userInterruptionEvent; HangupEvent hangupEvent; EndOfFileEvent endOfFileEvent; + LinkEvent linkEvent; CustomEvent customEvent; } } @@ -2950,9 +3645,16 @@ void main() { bool timeToBreak = false; + terminal.hyperlink("test", 4); + terminal.hyperlink("another", 7); + void handleEvent(InputEvent event) { - terminal.writef("%s\n", event.type); + //terminal.writef("%s\n", event.type); final switch(event.type) { + case InputEvent.Type.LinkEvent: + auto ev = event.get!(InputEvent.Type.LinkEvent); + terminal.writeln(ev); + break; case InputEvent.Type.UserInterruptionEvent: case InputEvent.Type.HangupEvent: case InputEvent.Type.EndOfFileEvent: @@ -2993,13 +3695,13 @@ void main() { terminal.writef("\t%s\n", event.get!(InputEvent.Type.PasteEvent)); break; case InputEvent.Type.MouseEvent: - terminal.writef("\t%s\n", event.get!(InputEvent.Type.MouseEvent)); + //terminal.writef("\t%s\n", event.get!(InputEvent.Type.MouseEvent)); break; case InputEvent.Type.CustomEvent: break; } - terminal.writefln("%d %d", terminal.cursorX, terminal.cursorY); + //terminal.writefln("%d %d", terminal.cursorX, terminal.cursorY); /* if(input.kbhit()) { @@ -3028,10 +3730,20 @@ void main() { } } +enum TerminalCapabilities : uint { + minimal = 0, + vt100 = 1 << 0, + + // my special terminal emulator extensions + arsdClipboard = 1 << 15, // 90 in caps + arsdImage = 1 << 16, // 91 in caps + arsdHyperlinks = 1 << 17, // 92 in caps +} + version(Posix) -private int[] getTerminalCapabilities(int fdIn, int fdOut) { +private uint /* TerminalCapabilities bitmask */ getTerminalCapabilities(int fdIn, int fdOut) { if(fdIn == -1 || fdOut == -1) - return null; + return TerminalCapabilities.minimal; import std.conv; import core.stdc.errno; @@ -3064,7 +3776,7 @@ private int[] getTerminalCapabilities(int fdIn, int fdOut) { timeval tv; tv.tv_sec = 0; - tv.tv_usec = 10 * 1000; + tv.tv_usec = 250 * 1000; // 250 ms fd_set fs; FD_ZERO(&fs); @@ -3086,7 +3798,7 @@ private int[] getTerminalCapabilities(int fdIn, int fdOut) { } } else { // no data... assume terminal doesn't support giving an answer - return null; + return TerminalCapabilities.minimal; } ubyte[] answer; @@ -3143,12 +3855,25 @@ private int[] getTerminalCapabilities(int fdIn, int fdOut) { import std.string; auto pieces = split(gots, ";"); - int[] ret; + uint ret = TerminalCapabilities.vt100; foreach(p; pieces) - ret ~= p.to!int; + switch(p) { + case "90": + ret |= TerminalCapabilities.arsdClipboard; + break; + case "91": + ret |= TerminalCapabilities.arsdImage; + break; + case "92": + ret |= TerminalCapabilities.arsdHyperlinks; + break; + default: + } return ret; } +private extern(C) int mkstemp(char *templ); + /** FIXME: support lines that wrap FIXME: better controls maybe @@ -3265,12 +3990,28 @@ class LineGetter { private string prompt_; private int promptLength; - /// Turn on auto suggest if you want a greyed thing of what tab - /// would be able to fill in as you type. - /// - /// You might want to turn it off if generating a completion list is slow. - bool autoSuggest = true; + /++ + Turn on auto suggest if you want a greyed thing of what tab + would be able to fill in as you type. + You might want to turn it off if generating a completion list is slow. + + Or if you know you want it, be sure to turn it on explicitly in your + code because I reserve the right to change the default without advance notice. + + History: + On March 4, 2020, I changed the default to `false` because it + is kinda slow and not useful in all cases. + +/ + bool autoSuggest = false; + + /++ + Returns true if there was any input in the buffer. Can be + checked in the case of a [UserInterruptionException]. + +/ + bool hadInput() { + return line.length > 0; + } /// Override this if you don't want all lines added to the history. /// You can return null to not add it at all, or you can transform it. @@ -3290,9 +4031,17 @@ class LineGetter { file.writeln(item); } + /++ + History: + Introduced on January 31, 2020 + +/ + /* virtual */ string historyFileExtension() { + return ".history"; + } + private string historyPath() { import std.path; - auto filename = historyFileDirectory() ~ dirSeparator ~ historyFilename ~ ".history"; + auto filename = historyFileDirectory() ~ dirSeparator ~ historyFilename ~ historyFileExtension(); return filename; } @@ -3309,21 +4058,85 @@ class LineGetter { } } - /** + /++ Override this to provide tab completion. You may use the candidate argument to filter the list, but you don't have to (LineGetter will - do it for you on the values you return). + do it for you on the values you return). This means you can ignore + the arguments if you like. Ideally, you wouldn't return more than about ten items since the list gets difficult to use if it is too long. + Tab complete cannot modify text before or after the cursor at this time. + I *might* change that later to allow tab complete to fuzzy search and spell + check fix before. But right now it ONLY inserts. + Default is to provide recent command history as autocomplete. - */ - /* virtual */ protected string[] tabComplete(in dchar[] candidate) { + + Returns: + This function should return the full string to replace + `candidate[tabCompleteStartPoint(args) .. $]`. + For example, if your user wrote `wri` and you want to complete + it to `write` or `writeln`, you should return `["write", "writeln"]`. + + If you offer different tab complete in different places, you still + need to return the whole string. For example, a file competition of + a second argument, when the user writes `terminal.d term` and you + want it to complete to an additional `terminal.d`, you should return + `["terminal.d terminal.d"]`; in other words, `candidate ~ completion` + for each completion. + + It does this so you can simply return an array of words without having + to rebuild that array for each combination. + + To choose the word separator, override [tabCompleteStartPoint]. + + Params: + candidate = the text of the line up to the text cursor, after + which the completed text would be inserted + + afterCursor = the remaining text after the cursor. You can inspect + this, but cannot change it - this will be appended to the line + after completion, keeping the cursor in the same relative location. + + History: + Prior to January 30, 2020, this method took only one argument, + `candidate`. It now takes `afterCursor` as well, to allow you to + make more intelligent completions with full context. + +/ + /* virtual */ protected string[] tabComplete(in dchar[] candidate, in dchar[] afterCursor) { return history.length > 20 ? history[0 .. 20] : history; } - private string[] filterTabCompleteList(string[] list) { + /++ + Override this to provide a different tab competition starting point. The default + is `0`, always completing the complete line, but you may return the index of another + character of `candidate` to provide a new split. + + Returns: + The index of `candidate` where we should start the slice to keep in [tabComplete]. + It must be `>= 0 && <= candidate.length`. + + History: + Added on February 1, 2020. Initial default is to return 0 to maintain + old behavior. + +/ + /* virtual */ protected size_t tabCompleteStartPoint(in dchar[] candidate, in dchar[] afterCursor) { + return 0; + } + + /++ + This gives extra information for an item when displaying tab competition details. + + History: + Added January 31, 2020. + + +/ + /* virtual */ protected string tabCompleteHelp(string candidate) { + return null; + } + + private string[] filterTabCompleteList(string[] list, size_t start) { if(list.length == 0) return list; @@ -3332,28 +4145,82 @@ class LineGetter { foreach(item; list) { import std.algorithm; - if(startsWith(item, line[0 .. cursorPosition])) + if(startsWith(item, line[start .. cursorPosition])) f ~= item; } + /+ + // if it is excessively long, let's trim it down by trying to + // group common sub-sequences together. + if(f.length > terminal.height * 3 / 4) { + import std.algorithm; + f.sort(); + + // see how many can be saved by just keeping going until there is + // no more common prefix. then commit that and keep on down the list. + // since it is sorted, if there is a commonality, it should appear quickly + string[] n; + string commonality = f[0]; + size_t idx = 1; + while(idx < f.length) { + auto c = commonPrefix(commonality, f[idx]); + if(c.length > cursorPosition - start) { + commonality = c; + } else { + n ~= commonality; + commonality = f[idx]; + } + idx++; + } + if(commonality.length) + n ~= commonality; + + if(n.length) + f = n; + } + +/ + return f; } - /// Override this to provide a custom display of the tab completion list + /++ + Override this to provide a custom display of the tab completion list. + + History: + Prior to January 31, 2020, it only displayed the list. After + that, it would call [tabCompleteHelp] for each candidate and display + that string (if present) as well. + +/ protected void showTabCompleteList(string[] list) { if(list.length) { // FIXME: allow mouse clicking of an item, that would be cool + auto start = tabCompleteStartPoint(line[0 .. cursorPosition], line[cursorPosition .. $]); + // FIXME: scroll //if(terminal.type == ConsoleOutputType.linear) { terminal.writeln(); foreach(item; list) { terminal.color(suggestionForeground, background); import std.utf; - auto idx = codeLength!char(line[0 .. cursorPosition]); + auto idx = codeLength!char(line[start .. cursorPosition]); terminal.write(" ", item[0 .. idx]); terminal.color(regularForeground, background); - terminal.writeln(item[idx .. $]); + terminal.write(item[idx .. $]); + auto help = tabCompleteHelp(item); + if(help !is null) { + import std.string; + help = help.replace("\t", " ").replace("\n", " ").replace("\r", " "); + terminal.write("\t\t"); + int remaining; + if(terminal.cursorX + 2 < terminal.width) { + remaining = terminal.width - terminal.cursorX - 2; + } + if(remaining > 8) + terminal.write(remaining < help.length ? help[0 .. remaining] : help); + } + terminal.writeln(); + } updateCursorPosition(); redraw(); @@ -3361,6 +4228,86 @@ class LineGetter { } } + /++ + Called by the default event loop when the user presses F1. Override + `showHelp` to change the UI, override [helpMessage] if you just want + to change the message. + + History: + Introduced on January 30, 2020 + +/ + protected void showHelp() { + terminal.writeln(); + terminal.writeln(helpMessage); + updateCursorPosition(); + redraw(); + } + + /++ + History: + Introduced on January 30, 2020 + +/ + protected string helpMessage() { + return "Press F2 to edit current line in your editor. F3 searches. F9 runs current line while maintaining current edit state."; + } + + /++ + History: + Introduced on January 30, 2020 + +/ + protected dchar[] editLineInEditor(in dchar[] line, in size_t cursorPosition) { + import std.conv; + import std.process; + import std.file; + + char[] tmpName; + + version(Windows) { + import core.stdc.string; + char[280] path; + auto l = GetTempPathA(cast(DWORD) path.length, path.ptr); + if(l == 0) throw new Exception("GetTempPathA"); + path[l] = 0; + char[280] name; + auto r = GetTempFileNameA(path.ptr, "adr", 0, name.ptr); + if(r == 0) throw new Exception("GetTempFileNameA"); + tmpName = name[0 .. strlen(name.ptr)]; + scope(exit) + std.file.remove(tmpName); + std.file.write(tmpName, to!string(line)); + + string editor = environment.get("EDITOR", "notepad.exe"); + } else { + import core.stdc.stdlib; + import core.sys.posix.unistd; + char[120] name; + string p = "/tmp/adrXXXXXX"; + name[0 .. p.length] = p[]; + name[p.length] = 0; + auto fd = mkstemp(name.ptr); + tmpName = name[0 .. p.length]; + if(fd == -1) throw new Exception("mkstemp"); + scope(exit) + close(fd); + scope(exit) + std.file.remove(tmpName); + + string s = to!string(line); + while(s.length) { + auto x = write(fd, s.ptr, s.length); + if(x == -1) throw new Exception("write"); + s = s[x .. $]; + } + string editor = environment.get("EDITOR", "vi"); + } + + // FIXME the spawned process changes terminal state! + + spawnProcess([editor, tmpName]).wait; + import std.string; + return to!(dchar[])(cast(char[]) std.file.read(tmpName)).chomp; + } + //private RealTimeConsoleInput* rtci; /// One-call shop for the main workhorse @@ -3373,11 +4320,11 @@ class LineGetter { auto i = RealTimeConsoleInput(terminal, ConsoleInputFlags.raw | ConsoleInputFlags.allInputEvents | ConsoleInputFlags.noEolWrap); //rtci = &i; //scope(exit) rtci = null; - while(workOnLine(i.nextEvent())) {} + while(workOnLine(i.nextEvent(), &i)) {} } else { //rtci = input; //scope(exit) rtci = null; - while(workOnLine(input.nextEvent())) {} + while(workOnLine(input.nextEvent(), input)) {} } return finishGettingLine(); } @@ -3447,9 +4394,11 @@ class LineGetter { private string suggestion(string[] list = null) { import std.algorithm, std.utf; auto relevantLineSection = line[0 .. cursorPosition]; + auto start = tabCompleteStartPoint(relevantLineSection, line[cursorPosition .. $]); + relevantLineSection = relevantLineSection[start .. $]; // FIXME: see about caching the list if we easily can if(list is null) - list = filterTabCompleteList(tabComplete(relevantLineSection)); + list = filterTabCompleteList(tabComplete(relevantLineSection, line[cursorPosition .. $]), start); if(list.length) { string commonality = list[0]; @@ -3507,8 +4456,10 @@ class LineGetter { /// void deleteToEndOfLine() { - while(cursorPosition < line.length) - deleteChar(); + line = line[0 .. cursorPosition]; + line.assumeSafeAppend(); + //while(cursorPosition < line.length) + //deleteChar(); } int availableLineLength() { @@ -3519,8 +4470,18 @@ class LineGetter { void redraw() { terminal.hideCursor(); scope(exit) { - terminal.flush(); - terminal.showCursor(); + version(Win32Console) { + // on Windows, we want to make sure all + // is displayed before the cursor jumps around + terminal.flush(); + terminal.showCursor(); + } else { + // but elsewhere, the showCursor is itself buffered, + // so we can do it all at once for a slight speed boost + terminal.showCursor(); + //import std.string; import std.stdio; writeln(terminal.writeBuffer.replace("\033", "\\e")); + terminal.flush(); + } } terminal.moveTo(startOfLineX, startOfLineY); @@ -3607,15 +4568,19 @@ class LineGetter { /// function or else you might lose events or get exceptions from this. void startGettingLine() { // reset from any previous call first - cursorPosition = 0; - horizontalScrollPosition = 0; - justHitTab = false; - currentHistoryViewPosition = 0; - if(line.length) { - line = line[0 .. 0]; - line.assumeSafeAppend(); + if(!maintainBuffer) { + cursorPosition = 0; + horizontalScrollPosition = 0; + justHitTab = false; + currentHistoryViewPosition = 0; + if(line.length) { + line = line[0 .. 0]; + line.assumeSafeAppend(); + } } + maintainBuffer = false; + initializeWithSize(true); terminal.cursor = TerminalCursor.insert; @@ -3657,7 +4622,7 @@ class LineGetter { positionCursor(); } - lastDrawLength = terminal.width; + lastDrawLength = terminal.width - terminal.cursorX; version(Win32Console) lastDrawLength -= 1; // I don't like this but Windows resizing is different anyway and it is liable to scroll if i go over.. @@ -3668,7 +4633,10 @@ class LineGetter { terminal.flush(); // then get the current cursor position to start fresh - version(Win32Console) { + version(TerminalDirectToEmulator) { + startOfLineX = terminal.tew.terminalEmulator.cursorX; + startOfLineY = terminal.tew.terminalEmulator.cursorY; + } else version(Win32Console) { CONSOLE_SCREEN_BUFFER_INFO info; GetConsoleScreenBufferInfo(terminal.hConsole, &info); startOfLineX = info.dwCursorPosition.X; @@ -3707,49 +4675,72 @@ class LineGetter { import core.stdc.errno; import core.sys.posix.unistd; - // reading directly to bypass any buffering - int retries = 16; - ubyte[16] buffer; - try_again: - auto len = read(terminal.fdIn, buffer.ptr, buffer.length); - if(len <= 0) { + + ubyte readOne() { + ubyte[1] buffer; + int tries = 0; + try_again: + if(tries > 30) + throw new Exception("terminal reply timed out"); + auto len = read(terminal.fdIn, buffer.ptr, buffer.length); if(len == -1) { if(errno == EINTR) goto try_again; if(errno == EAGAIN || errno == EWOULDBLOCK) { import core.thread; Thread.sleep(10.msecs); + tries++; goto try_again; } + } else if(len == 0) { + throw new Exception("Couldn't get cursor position to initialize get line " ~ to!string(len) ~ " " ~ to!string(errno)); } - throw new Exception("Couldn't get cursor position to initialize get line " ~ to!string(len) ~ " " ~ to!string(errno)); - } - regot: - auto got = buffer[0 .. len]; - if(got.length < 6) { - auto len2 = read(terminal.fdIn, &buffer[len], buffer.length - len); - if(len2 <= 0) - throw new Exception("not enough cursor reply answer"); - else { - len += len2; - goto regot; - } - } - if(got[0] != '\033' || got[1] != '[' || got[$-1] != 'R') { - retries--; - if(retries > 0) - goto try_again; - throw new Exception("wrong answer for cursor position " ~ cast(string) got[1 .. $]); - } - auto gots = cast(char[]) got[2 .. $-1]; - import std.string; + return buffer[0]; + } - auto pieces = split(gots, ";"); - if(pieces.length != 2) throw new Exception("wtf wrong answer on cursor position"); + nextEscape: + while(readOne() != '\033') {} + if(readOne() != '[') + goto nextEscape; - startOfLineX = to!int(pieces[1]) - 1; - startOfLineY = to!int(pieces[0]) - 1; + int x, y; + + // now we should have some numbers being like yyy;xxxR + // but there may be a ? in there too; DEC private mode format + // of the very same data. + + x = 0; + y = 0; + + auto b = readOne(); + + if(b == '?') + b = readOne(); // no big deal, just ignore and continue + + nextNumberY: + if(b >= '0' || b <= '9') { + y *= 10; + y += b - '0'; + } else goto nextEscape; + + b = readOne(); + if(b != ';') + goto nextNumberY; + + nextNumberX: + b = readOne(); + if(b >= '0' || b <= '9') { + x *= 10; + x += b - '0'; + } else goto nextEscape; + + b = readOne(); + if(b != 'R') + goto nextEscape; // it wasn't the right thing it after all + + startOfLineX = x - 1; + startOfLineY = y - 1; } // updating these too because I can with the more accurate info from above @@ -3767,12 +4758,26 @@ class LineGetter { return s; } - /// for integrating into another event loop - /// you can pass individual events to this and - /// the line getter will work on it - /// - /// returns false when there's nothing more to do - bool workOnLine(InputEvent e) { + void showIndividualHelp(string help) { + terminal.writeln(); + terminal.writeln(help); + } + + private bool maintainBuffer; + + /++ + for integrating into another event loop + you can pass individual events to this and + the line getter will work on it + + returns false when there's nothing more to do + + History: + On February 17, 2020, it was changed to take + a new argument which should be the input source + where the event came from. + +/ + bool workOnLine(InputEvent e, RealTimeConsoleInput* rtti = null) { switch(e.type) { case InputEvent.Type.EndOfFileEvent: justHitTab = false; @@ -3799,7 +4804,9 @@ class LineGetter { return false; case '\t': auto relevantLineSection = line[0 .. cursorPosition]; - auto possibilities = filterTabCompleteList(tabComplete(relevantLineSection)); + auto start = tabCompleteStartPoint(relevantLineSection, line[cursorPosition .. $]); + relevantLineSection = relevantLineSection[start .. $]; + auto possibilities = filterTabCompleteList(tabComplete(relevantLineSection, line[cursorPosition .. $]), start); import std.utf; if(possibilities.length == 1) { @@ -3807,6 +4814,13 @@ class LineGetter { if(toFill.length) { addString(toFill); redraw(); + } else { + auto help = this.tabCompleteHelp(possibilities[0]); + if(help.length) { + showIndividualHelp(help); + updateCursorPosition(); + redraw(); + } } justHitTab = false; } else { @@ -3843,6 +4857,48 @@ class LineGetter { redraw(); } break; + case KeyboardEvent.Key.escape: + justHitTab = false; + cursorPosition = 0; + horizontalScrollPosition = 0; + line = line[0 .. 0]; + line.assumeSafeAppend(); + redraw(); + break; + case KeyboardEvent.Key.F1: + justHitTab = false; + showHelp(); + break; + case KeyboardEvent.Key.F2: + justHitTab = false; + line = editLineInEditor(line, cursorPosition); + if(cursorPosition > line.length) + cursorPosition = cast(int) line.length; + if(horizontalScrollPosition > line.length) + horizontalScrollPosition = cast(int) line.length; + positionCursor(); + redraw(); + break; + case KeyboardEvent.Key.F3: + // case 'r' - 'a' + 1: // ctrl+r + justHitTab = false; + // search in history + // FIXME: what about search in completion too? + break; + case KeyboardEvent.Key.F4: + justHitTab = false; + // FIXME: clear line + break; + case KeyboardEvent.Key.F9: + justHitTab = false; + // compile and run analog; return the current string + // but keep the buffer the same + maintainBuffer = true; + return false; + case 0x1d: // ctrl+5, because of vim % shortcut + justHitTab = false; + // FIXME: find matching delimiter + break; case KeyboardEvent.Key.LeftArrow: justHitTab = false; if(cursorPosition) @@ -3852,12 +4908,9 @@ class LineGetter { cursorPosition--; } aligned(cursorPosition, -1); - if(!multiLineMode) { - if(cursorPosition < horizontalScrollPosition) { - horizontalScrollPosition--; - aligned(horizontalScrollPosition, -1); - } - } + + if(cursorPosition < horizontalScrollPosition) + positionCursor(); redraw(); break; @@ -3874,12 +4927,9 @@ class LineGetter { cursorPosition = cast(int) line.length; } aligned(cursorPosition, 1); - if(!multiLineMode) { - if(cursorPosition >= horizontalScrollPosition + availableLineLength()) { - horizontalScrollPosition++; - aligned(horizontalScrollPosition, 1); - } - } + + if(cursorPosition > horizontalScrollPosition + availableLineLength()) + positionCursor(); redraw(); break; @@ -3917,6 +4967,10 @@ class LineGetter { scrollToEnd(); redraw(); break; + case ('v' - 'a' + 1): + if(rtti) + rtti.requestPasteFromClipboard(); + break; case KeyboardEvent.Key.Insert: justHitTab = false; if(ev.modifierState & ModifierState.shift) { @@ -3927,8 +4981,11 @@ class LineGetter { // those work on Windows!!!! and many linux TEs too. // but if it does make it here, we'll attempt it at this level + if(rtti) + rtti.requestPasteFromClipboard(); } else if(ev.modifierState & ModifierState.control) { // copy + // FIXME } else { insertMode = !insertMode; @@ -3982,6 +5039,10 @@ class LineGetter { } } } + if(me.buttons & MouseEvent.Button.Middle) { + if(rtti) + rtti.requestPasteFromPrimary(); + } } break; case InputEvent.Type.SizeChangedEvent: @@ -4033,21 +5094,19 @@ class FileLineGetter : LineGetter { /// to complete. string searchDirectory = "."; - override protected string[] tabComplete(in dchar[] candidate) { + override size_t tabCompleteStartPoint(in dchar[] candidate, in dchar[] afterCursor) { + import std.string; + return candidate.lastIndexOf(" ") + 1; + } + + override protected string[] tabComplete(in dchar[] candidate, in dchar[] afterCursor) { import std.file, std.conv, std.algorithm, std.string; - const(dchar)[] soFar = candidate; - auto idx = candidate.lastIndexOf(" "); - if(idx != -1) - soFar = candidate[idx + 1 .. $]; string[] list; foreach(string name; dirEntries(searchDirectory, SpanMode.breadth)) { - // try without the ./ - if(startsWith(name[2..$], soFar)) - list ~= text(candidate, name[searchDirectory.length + 1 + soFar.length .. $]); - else // and with - if(startsWith(name, soFar)) - list ~= text(candidate, name[soFar.length .. $]); + // both with and without the (searchDirectory ~ "/") + list ~= name[searchDirectory.length + 1 .. $]; + list ~= name[0 .. $]; } return list; @@ -4228,10 +5287,10 @@ struct ScrollbackBuffer { } } - T opIndex(int idx) { + ref T opIndex(int idx) { return backing[(start + idx) % maxScrollback]; } - T opIndex(Dollar idx) { + ref T opIndex(Dollar idx) { return backing[(start + (length + idx.offsetFromEnd)) % maxScrollback]; } @@ -4255,14 +5314,14 @@ struct ScrollbackBuffer { remaining = count; } - T front() { return (*item)[position]; } + ref T front() { return (*item)[position]; } bool empty() { return remaining <= 0; } void popFront() { position++; remaining--; } - T back() { return (*item)[remaining - 1 - position]; } + ref T back() { return (*item)[remaining - 1 - position]; } void popBack() { remaining--; } @@ -4502,6 +5561,9 @@ struct ScrollbackBuffer { /// Returns true if it should be redrawn bool handleEvent(InputEvent e) { final switch(e.type) { + case InputEvent.Type.LinkEvent: + // meh + break; case InputEvent.Type.KeyboardEvent: auto ev = e.keyboardEvent; @@ -4805,6 +5867,681 @@ int approximate16Color(RGB color) { return c; } +version(TerminalDirectToEmulator) { + + /++ + Indicates the TerminalDirectToEmulator features + are present. You can check this with `static if`. + + $(WARNING + This will cause the [Terminal] constructor to spawn a GUI thread with [arsd.minigui]/[arsd.simpledisplay]. + + This means you can NOT use those libraries in your + own thing without using the [arsd.simpledisplay.runInGuiThread] helper since otherwise the main thread is inaccessible, since having two different threads creating event loops or windows is undefined behavior with those libraries. + ) + +/ + enum IntegratedEmulator = true; + + /++ + Allows customization of the integrated emulator window. + You may change the default colors, font, and other aspects + of GUI integration. + + Test for its presence before using with `static if(arsd.terminal.IntegratedEmulator)`. + + All settings here must be set BEFORE you construct any [Terminal] instances. + + History: + Added March 7, 2020. + +/ + struct IntegratedTerminalEmulatorConfiguration { + /// Note that all Colors in here are 24 bit colors. + alias Color = arsd.color.Color; + + /// Default foreground color of the terminal. + Color defaultForeground = Color.black; + /// Default background color of the terminal. + Color defaultBackground = Color.white; + + /++ + Font to use in the window. It should be a monospace font, + and your selection may not actually be used if not available on + the user's system, in which case it will fallback to one. + + History: + Implemented March 26, 2020 + +/ + string fontName; + /// ditto + int fontSize = 14; + + /++ + Requested initial terminal size in character cells. You may not actually get exactly this. + +/ + int initialWidth = 80; + /// ditto + int initialHeight = 40; + + /++ + If `true`, the window will close automatically when the main thread exits. + Otherwise, the window will remain open so the user can work with output before + it disappears. + + History: + Added April 10, 2020 (v7.2.0) + +/ + bool closeOnExit = false; + + /++ + Gives you a chance to modify the window as it is constructed. Intended + to let you add custom menu options. + + --- + import arsd.terminal; + integratedTerminalEmulatorConfiguration.menuExtensionsConstructor = (TerminalEmulatorWindow window) { + import arsd.minigui; // for the menu related UDAs + class Commands { + @menu("Help") { + void Topics() { + auto window = new Window(); // make a help window of some sort + window.show(); + } + + @separator + + void About() { + messageBox("My Application v 1.0"); + } + } + } + window.setMenuAndToolbarFromAnnotatedCode(new Commands()); + }; + --- + + History: + Added March 29, 2020. Included in release v7.1.0. + +/ + void delegate(TerminalEmulatorWindow) menuExtensionsConstructor; + } + + /+ + status bar should probably tell + if scroll lock is on... + +/ + + /// You can set this in a static module constructor. (`shared static this() {}`) + __gshared IntegratedTerminalEmulatorConfiguration integratedTerminalEmulatorConfiguration; + + import arsd.terminalemulator; + import arsd.minigui; + + /++ + Represents the window that the library pops up for you. + +/ + final class TerminalEmulatorWindow : MainWindow { + + /++ + Gives access to the underlying terminal emulation object. + +/ + TerminalEmulator terminalEmulator() { + return tew.terminalEmulator; + } + + private TerminalEmulatorWindow parent; + private TerminalEmulatorWindow[] children; + private void childClosing(TerminalEmulatorWindow t) { + foreach(idx, c; children) + if(c is t) + children = children[0 .. idx] ~ children[idx + 1 .. $]; + } + private void registerChild(TerminalEmulatorWindow t) { + children ~= t; + } + + private this(Terminal* term, TerminalEmulatorWindow parent) { + + this.parent = parent; + scope(success) if(parent) parent.registerChild(this); + + super("Terminal Application", integratedTerminalEmulatorConfiguration.initialWidth * integratedTerminalEmulatorConfiguration.fontSize / 2, integratedTerminalEmulatorConfiguration.initialHeight * integratedTerminalEmulatorConfiguration.fontSize); + + smw = new ScrollMessageWidget(this); + tew = new TerminalEmulatorWidget(term, smw); + + smw.addEventListener("scroll", () { + tew.terminalEmulator.scrollbackTo(smw.position.x, smw.position.y + tew.terminalEmulator.height); + redraw(); + }); + + smw.setTotalArea(1, 1); + + setMenuAndToolbarFromAnnotatedCode(this); + if(integratedTerminalEmulatorConfiguration.menuExtensionsConstructor) + integratedTerminalEmulatorConfiguration.menuExtensionsConstructor(this); + } + + TerminalEmulator.TerminalCell[] delegate(TerminalEmulator.TerminalCell[] i) parentFilter; + + private void addScrollbackLineFromParent(TerminalEmulator.TerminalCell[] lineIn) { + if(parentFilter is null) + return; + + auto line = parentFilter(lineIn); + if(line is null) return; + + if(tew && tew.terminalEmulator) { + bool atBottom = smw.verticalScrollBar.atEnd && smw.horizontalScrollBar.atStart; + tew.terminalEmulator.addScrollbackLine(line); + tew.terminalEmulator.notifyScrollbackAdded(); + if(atBottom) { + tew.terminalEmulator.notifyScrollbarPosition(0, int.max); + tew.terminalEmulator.scrollbackTo(0, int.max); + tew.redraw(); + } + } + } + + private TerminalEmulatorWidget tew; + private ScrollMessageWidget smw; + + @menu("&History") { + @tip("Saves the currently visible content to a file") + void Save() { + getSaveFileName((string name) { + tew.terminalEmulator.writeScrollbackToFile(name); + }); + } + + // FIXME + version(FIXME) + void Save_HTML() { + + } + + @separator + /* + void Find() { + // FIXME + // jump to the previous instance in the scrollback + + } + */ + + void Filter() { + // open a new window that just shows items that pass the filter + + static struct FilterParams { + string searchTerm; + bool caseSensitive; + } + + dialog((FilterParams p) { + auto nw = new TerminalEmulatorWindow(null, this); + + nw.parentFilter = (TerminalEmulator.TerminalCell[] line) { + import std.algorithm; + import std.uni; + // omg autodecoding being kinda useful for once LOL + if(line.map!(c => c.hasNonCharacterData ? dchar(0) : (p.caseSensitive ? c.ch : c.ch.toLower)). + canFind(p.searchTerm)) + { + // I might highlight the match too, but meh for now + return line; + } + return null; + }; + + foreach(line; tew.terminalEmulator.sbb[0 .. $]) { + if(auto l = nw.parentFilter(line)) + nw.tew.terminalEmulator.addScrollbackLine(l); + } + nw.tew.terminalEmulator.toggleScrollLock(); + nw.tew.terminalEmulator.drawScrollback(); + nw.title = "Filter Display"; + nw.show(); + }); + + } + + @separator + void Clear() { + tew.terminalEmulator.clearScrollbackHistory(); + tew.terminalEmulator.cls(); + tew.terminalEmulator.moveCursor(0, 0); + if(tew.term) { + tew.term.windowSizeChanged = true; + tew.terminalEmulator.outgoingSignal.notify(); + } + tew.redraw(); + } + + @separator + void Exit() @accelerator("Alt+F4") @hotkey('x') { + this.close(); + } + } + + @menu("&Edit") { + void Copy() { + tew.terminalEmulator.copyToClipboard(tew.terminalEmulator.getSelectedText()); + } + + void Paste() { + tew.terminalEmulator.pasteFromClipboard(&tew.terminalEmulator.sendPasteData); + } + } + } + + private class InputEventInternal { + const(ubyte)[] data; + this(in ubyte[] data) { + this.data = data; + } + } + + private class TerminalEmulatorWidget : Widget { + + Menu ctx; + + override Menu contextMenu(int x, int y) { + if(ctx is null) { + ctx = new Menu(""); + ctx.addItem(new MenuItem(new Action("Copy", 0, { + terminalEmulator.copyToClipboard(terminalEmulator.getSelectedText()); + }))); + ctx.addItem(new MenuItem(new Action("Paste", 0, { + terminalEmulator.pasteFromClipboard(&terminalEmulator.sendPasteData); + }))); + ctx.addItem(new MenuItem(new Action("Toggle Scroll Lock", 0, { + terminalEmulator.toggleScrollLock(); + }))); + } + return ctx; + } + + this(Terminal* term, ScrollMessageWidget parent) { + this.smw = parent; + this.term = term; + terminalEmulator = new TerminalEmulatorInsideWidget(this); + super(parent); + this.parentWindow.win.onClosing = { + if(term) + term.hangedUp = true; + + if(auto wi = cast(TerminalEmulatorWindow) this.parentWindow) { + if(wi.parent) + wi.parent.childClosing(wi); + } + + // try to get it to terminate slightly more forcibly too, if possible + if(sigIntExtension) + sigIntExtension(); + + terminalEmulator.outgoingSignal.notify(); + terminalEmulator.incomingSignal.notify(); + }; + + this.parentWindow.win.addEventListener((InputEventInternal ie) { + terminalEmulator.sendRawInput(ie.data); + this.redraw(); + terminalEmulator.incomingSignal.notify(); + }); + } + + ScrollMessageWidget smw; + Terminal* term; + + void sendRawInput(const(ubyte)[] data) { + if(this.parentWindow) { + this.parentWindow.win.postEvent(new InputEventInternal(data)); + terminalEmulator.incomingSignal.wait(); // blocking write basically, wait until the TE confirms the receipt of it + } + } + + TerminalEmulatorInsideWidget terminalEmulator; + + override void registerMovement() { + super.registerMovement(); + terminalEmulator.resized(width, height); + } + + override void focus() { + super.focus(); + terminalEmulator.attentionReceived(); + } + + override MouseCursor cursor() { return GenericCursor.Text; } + + override void erase(WidgetPainter painter) { /* intentionally blank, paint does it better */ } + + override void paint(WidgetPainter painter) { + bool forceRedraw = false; + if(terminalEmulator.invalidateAll || terminalEmulator.clearScreenRequested) { + auto clearColor = terminalEmulator.defaultBackground; + painter.outlineColor = clearColor; + painter.fillColor = clearColor; + painter.drawRectangle(Point(0, 0), this.width, this.height); + terminalEmulator.clearScreenRequested = false; + forceRedraw = true; + } + + terminalEmulator.redrawPainter(painter, forceRedraw); + } + } + + private class TerminalEmulatorInsideWidget : TerminalEmulator { + + private ScrollbackBuffer sbb() { return scrollbackBuffer; } + + void resized(int w, int h) { + this.resizeTerminal(w / fontWidth, h / fontHeight); + if(widget && widget.smw) { + widget.smw.setViewableArea(this.width, this.height); + widget.smw.setPageSize(this.width / 2, this.height / 2); + } + clearScreenRequested = true; + if(widget && widget.term) + widget.term.windowSizeChanged = true; + outgoingSignal.notify(); + redraw(); + } + + override void addScrollbackLine(TerminalCell[] line) { + super.addScrollbackLine(line); + if(widget) + if(auto p = cast(TerminalEmulatorWindow) widget.parentWindow) { + foreach(child; p.children) + child.addScrollbackLineFromParent(line); + } + } + + override void notifyScrollbackAdded() { + widget.smw.setTotalArea(this.scrollbackWidth > this.width ? this.scrollbackWidth : this.width, this.scrollbackLength > this.height ? this.scrollbackLength : this.height); + } + + override void notifyScrollbarPosition(int x, int y) { + widget.smw.setPosition(x, y); + widget.redraw(); + } + + override void notifyScrollbarRelevant(bool isRelevantHorizontally, bool isRelevantVertically) { + if(isRelevantVertically) + notifyScrollbackAdded(); + else + widget.smw.setTotalArea(width, height); + } + + override @property public int cursorX() { return super.cursorX; } + override @property public int cursorY() { return super.cursorY; } + + protected override void changeCursorStyle(CursorStyle s) { } + + string currentTitle; + protected override void changeWindowTitle(string t) { + if(widget && widget.parentWindow && t.length) { + widget.parentWindow.win.title = t; + currentTitle = t; + } + } + protected override void changeWindowIcon(IndexedImage t) { + if(widget && widget.parentWindow && t) + widget.parentWindow.win.icon = t; + } + + protected override void changeIconTitle(string) {} + protected override void changeTextAttributes(TextAttributes) {} + protected override void soundBell() { + static if(UsingSimpledisplayX11) + XBell(XDisplayConnection.get(), 50); + } + + protected override void demandAttention() { + if(widget && widget.parentWindow) + widget.parentWindow.win.requestAttention(); + } + + protected override void copyToClipboard(string text) { + setClipboardText(widget.parentWindow.win, text); + } + + override int maxScrollbackLength() const { + return int.max; // no scrollback limit for custom programs + } + + protected override void pasteFromClipboard(void delegate(in char[]) dg) { + static if(UsingSimpledisplayX11) + getPrimarySelection(widget.parentWindow.win, dg); + else + getClipboardText(widget.parentWindow.win, (in char[] dataIn) { + char[] data; + // change Windows \r\n to plain \n + foreach(char ch; dataIn) + if(ch != 13) + data ~= ch; + dg(data); + }); + } + + protected override void copyToPrimary(string text) { + static if(UsingSimpledisplayX11) + setPrimarySelection(widget.parentWindow.win, text); + else + {} + } + protected override void pasteFromPrimary(void delegate(in char[]) dg) { + static if(UsingSimpledisplayX11) + getPrimarySelection(widget.parentWindow.win, dg); + } + + override void requestExit() { + widget.parentWindow.close(); + } + + bool echo = false; + + override void sendRawInput(in ubyte[] data) { + void send(in ubyte[] data) { + if(data.length == 0) + return; + super.sendRawInput(data); + if(echo) + sendToApplication(data); + } + + // need to echo, translate 10 to 13/10 cr-lf + size_t last = 0; + const ubyte[2] crlf = [13, 10]; + foreach(idx, ch; data) { + if(ch == 10) { + send(data[last .. idx]); + send(crlf[]); + last = idx + 1; + } + } + + if(last < data.length) + send(data[last .. $]); + } + + bool focused; + + TerminalEmulatorWidget widget; + + import arsd.simpledisplay; + import arsd.color; + import core.sync.semaphore; + alias ModifierState = arsd.simpledisplay.ModifierState; + alias Color = arsd.color.Color; + alias fromHsl = arsd.color.fromHsl; + + const(ubyte)[] pendingForApplication; + Semaphore outgoingSignal; + Semaphore incomingSignal; + + override void sendToApplication(scope const(void)[] what) { + synchronized(this) { + pendingForApplication ~= cast(const(ubyte)[]) what; + } + outgoingSignal.notify(); + } + + @property int width() { return screenWidth; } + @property int height() { return screenHeight; } + + @property bool invalidateAll() { return super.invalidateAll; } + + private this(TerminalEmulatorWidget widget) { + + this.outgoingSignal = new Semaphore(); + this.incomingSignal = new Semaphore(); + + this.widget = widget; + + if(integratedTerminalEmulatorConfiguration.fontName.length) { + this.font = new OperatingSystemFont(integratedTerminalEmulatorConfiguration.fontName, integratedTerminalEmulatorConfiguration.fontSize, FontWeight.medium); + this.fontWidth = font.averageWidth; + this.fontHeight = font.height; + } + + + if(this.font is null || this.font.isNull) + loadDefaultFont(integratedTerminalEmulatorConfiguration.fontSize); + + super(integratedTerminalEmulatorConfiguration.initialWidth, integratedTerminalEmulatorConfiguration.initialHeight); + + defaultForeground = integratedTerminalEmulatorConfiguration.defaultForeground; + defaultBackground = integratedTerminalEmulatorConfiguration.defaultBackground; + + bool skipNextChar = false; + + widget.addEventListener("mousedown", (Event ev) { + int termX = (ev.clientX - paddingLeft) / fontWidth; + int termY = (ev.clientY - paddingTop) / fontHeight; + + if((!mouseButtonTracking || (ev.state & ModifierState.shift)) && ev.button == MouseButton.right) + widget.showContextMenu(ev.clientX, ev.clientY); + else + if(sendMouseInputToApplication(termX, termY, + arsd.terminalemulator.MouseEventType.buttonPressed, + cast(arsd.terminalemulator.MouseButton) ev.button, + (ev.state & ModifierState.shift) ? true : false, + (ev.state & ModifierState.ctrl) ? true : false, + (ev.state & ModifierState.alt) ? true : false + )) + redraw(); + }); + + widget.addEventListener("mouseup", (Event ev) { + int termX = (ev.clientX - paddingLeft) / fontWidth; + int termY = (ev.clientY - paddingTop) / fontHeight; + + if(sendMouseInputToApplication(termX, termY, + arsd.terminalemulator.MouseEventType.buttonReleased, + cast(arsd.terminalemulator.MouseButton) ev.button, + (ev.state & ModifierState.shift) ? true : false, + (ev.state & ModifierState.ctrl) ? true : false, + (ev.state & ModifierState.alt) ? true : false + )) + redraw(); + }); + + widget.addEventListener("mousemove", (Event ev) { + int termX = (ev.clientX - paddingLeft) / fontWidth; + int termY = (ev.clientY - paddingTop) / fontHeight; + + if(sendMouseInputToApplication(termX, termY, + arsd.terminalemulator.MouseEventType.motion, + cast(arsd.terminalemulator.MouseButton) ev.button, + (ev.state & ModifierState.shift) ? true : false, + (ev.state & ModifierState.ctrl) ? true : false, + (ev.state & ModifierState.alt) ? true : false + )) + redraw(); + }); + + widget.addEventListener("keydown", (Event ev) { + static string magic() { + string code; + foreach(member; __traits(allMembers, TerminalKey)) + if(member != "Escape") + code ~= "case Key." ~ member ~ ": if(sendKeyToApplication(TerminalKey." ~ member ~ " + , (ev.state & ModifierState.shift)?true:false + , (ev.state & ModifierState.alt)?true:false + , (ev.state & ModifierState.ctrl)?true:false + , (ev.state & ModifierState.windows)?true:false + )) redraw(); break;"; + return code; + } + + + switch(ev.key) { + mixin(magic()); + default: + // keep going, not special + } + + return; // the character event handler will do others + }); + + widget.addEventListener("char", (Event ev) { + dchar c = ev.character; + if(skipNextChar) { + skipNextChar = false; + return; + } + + endScrollback(); + char[4] str; + import std.utf; + if(c == '\n') c = '\r'; // terminal seem to expect enter to send 13 instead of 10 + auto data = str[0 .. encode(str, c)]; + + + if(c == 0x1c) /* ctrl+\, force quit */ { + version(Posix) { + import core.sys.posix.signal; + pthread_kill(widget.term.threadId, SIGQUIT); // or SIGKILL even? + + assert(0); + //import core.sys.posix.pthread; + //pthread_cancel(widget.term.threadId); + //widget.term = null; + } else version(Windows) { + import core.sys.windows.windows; + auto hnd = OpenProcess(SYNCHRONIZE | PROCESS_TERMINATE, TRUE, GetCurrentProcessId()); + TerminateProcess(hnd, -1); + assert(0); + } + } else if(c == 3) /* ctrl+c, interrupt */ { + if(sigIntExtension) + sigIntExtension(); + + if(widget && widget.term) { + widget.term.interrupted = true; + outgoingSignal.notify(); + } + } else if(c != 127) { + // on X11, the delete key can send a 127 character too, but that shouldn't be sent to the terminal since xterm shoots \033[3~ instead, which we handle in the KeyEvent handler. + sendToApplication(data); + } + }); + } + + bool clearScreenRequested = true; + void redraw() { + if(widget.parentWindow is null || widget.parentWindow.win is null || widget.parentWindow.win.closed) + return; + + widget.redraw(); + } + + mixin SdpyDraw; + } +} else { + /// + enum IntegratedEmulator = false; +} + /* void main() { auto terminal = Terminal(ConsoleOutputType.linear); @@ -4812,3 +6549,41 @@ void main() { terminal.writeln("Hello, world!"); } */ + + +/* + ONLY SUPPORTED ON MY TERMINAL EMULATOR IN GENERAL + + bracketed section can collapse and scroll independently in the TE. may also pop out into a window (possibly with a comparison window) + + hyperlink can either just indicate something to the TE to handle externally + OR + indicate a certain input sequence be triggered when it is clicked (prolly wrapped up as a paste event). this MAY also be a custom event. + + internally it can set two bits: one indicates it is a hyperlink, the other just flips each use to separate consecutive sequences. + + it might require the content of the paste event to be the visible word but it would bne kinda cool if it could be some secret thing elsewhere. + + + I could spread a unique id number across bits, one bit per char so the memory isn't too bad. + so it would set a number and a word. this is sent back to the application to handle internally. + + 1) turn on special input + 2) turn off special input + 3) special input sends a paste event with a number and the text + 4) to make a link, you write out the begin sequence, the text, and the end sequence. including the magic number somewhere. + magic number is allowed to have one bit per char. the terminal discards anything else. terminal.d api will enforce. + + if magic number is zero, it is not sent in the paste event. maybe. + + or if it is like 255, it is handled as a url and opened externally + tho tbh a url could just be detected by regex pattern + + + NOTE: if your program requests mouse input, the TE does not process it! Thus the user will have to shift+click for it. + + mode 3004 for bracketed hyperlink + + hyperlink sequence: \033[?220hnum;text\033[?220l~ + +*/ diff --git a/terminalemulator.d b/terminalemulator.d new file mode 100644 index 0000000..4eb8d6b --- /dev/null +++ b/terminalemulator.d @@ -0,0 +1,4474 @@ +/** + FIXME: writing a line in color then a line in ordinary does something + wrong. + + # huh if i do underline then change color it undoes the underline + + FIXME: make shift+enter send something special to the application + and shift+space, etc. + identify itself somehow too for client extensions + ctrl+space is supposed to send char 0. + + ctrl+click on url pattern could open in browser perhaps + + FIXME: scroll stuff should be higher level in the implementation. + so like scroll Rect, DirectionAndAmount + + There should be a redraw thing that is given batches of instructions + in here that the other thing just implements. + + FIXME: the save stack stuff should do cursor style too + + This is an extendible unix terminal emulator and some helper functions to help actually implement one. + + You'll have to subclass TerminalEmulator and implement the abstract functions as well as write a drawing function for it. + + See nestedterminalemulator.d or main.d for how I did it. +*/ +module arsd.terminalemulator; + +import arsd.color; +import std.algorithm : max; + +enum extensionMagicIdentifier = "ARSD Terminal Emulator binary extension data follows:"; + +/+ + The ;90 ones are my extensions. + + 90 - clipboard extensions + 91 - image extensions + 92 - hyperlink extensions ++/ +enum terminalIdCode = "\033[?64;1;2;6;9;15;16;17;18;21;22;28;90;91;92c"; + +interface NonCharacterData { + //const(ubyte)[] serialize(); +} + +struct BrokenUpImage { + int width; + int height; + TerminalEmulator.TerminalCell[] representation; +} + +struct CustomGlyph { + TrueColorImage image; + dchar substitute; +} + +void unknownEscapeSequence(in char[] esc) { + import std.file; + version(Posix) { + debug append("/tmp/arsd-te-bad-esc-sequences.txt", esc ~ "\n"); + } else { + debug append("arsd-te-bad-esc-sequences.txt", esc ~ "\n"); + } +} + +// This is used for the double-click word selection +bool isWordSeparator(dchar ch) { + return ch == ' ' || ch == '"' || ch == '<' || ch == '>' || ch == '(' || ch == ')' || ch == ','; +} + +TerminalEmulator.TerminalCell[] sliceTrailingWhitespace(TerminalEmulator.TerminalCell[] t) { + size_t end = t.length; + while(end >= 1) { + if(t[end-1].hasNonCharacterData || t[end-1].ch != ' ') + break; + end--; + } + + t = t[0 .. end]; + + /* + import std.stdio; + foreach(ch; t) + write(ch.ch); + writeln("*"); + */ + + return t; +} + +struct ScopeBuffer(T, size_t maxSize) { + T[maxSize] buffer; + size_t length; + bool isNull = true; + T[] opSlice() { return isNull ? null : buffer[0 .. length]; } + void opOpAssign(string op : "~")(in T rhs) { + isNull = false; + if(this.length < buffer.length) // i am silently discarding more crap + buffer[this.length++] = rhs; + } + void opOpAssign(string op : "~")(in T[] rhs) { + isNull = false; + buffer[this.length .. this.length + rhs.length] = rhs[]; + this.length += rhs.length; + } + void opAssign(in T[] rhs) { + isNull = rhs is null; + buffer[0 .. rhs.length] = rhs[]; + this.length = rhs.length; + } + void opAssign(typeof(null)) { + isNull = true; + length = 0; + } + T opIndex(size_t idx) { + assert(!isNull); + assert(idx < length); + return buffer[idx]; + } + void clear() { + isNull = true; + length = 0; + } +} + +/** + An abstract class that does terminal emulation. You'll have to subclass it to make it work. + + The terminal implements a subset of what xterm does and then, optionally, some special features. + + Its linear mode (normal) screen buffer is infinitely long and infinitely wide. It is the responsibility + of your subclass to do line wrapping, etc., for display. This i think is actually incompatible with xterm but meh. + + actually maybe it *should* automatically wrap them. idk. I think GNU screen does both. FIXME decide. + + Its cellular mode (alternate) screen buffer can be any size you want. +*/ +class TerminalEmulator { + /* override these to do stuff on the interface. + You might be able to stub them out if there's no state maintained on the target, since TerminalEmulator maintains its own internal state */ + protected abstract void changeWindowTitle(string); /// the title of the window + protected abstract void changeIconTitle(string); /// the shorter window/iconified window + + protected abstract void changeWindowIcon(IndexedImage); /// change the window icon. note this may be null + + protected abstract void changeCursorStyle(CursorStyle); /// cursor style + + protected abstract void changeTextAttributes(TextAttributes); /// current text output attributes + protected abstract void soundBell(); /// sounds the bell + protected abstract void sendToApplication(scope const(void)[]); /// send some data to the program running in the terminal, so keypresses etc. + + protected abstract void copyToClipboard(string); /// copy the given data to the clipboard (or you can do nothing if you can't) + protected abstract void pasteFromClipboard(void delegate(in char[])); /// requests a paste. we pass it a delegate that should accept the data + + protected abstract void copyToPrimary(string); /// copy the given data to the PRIMARY X selection (or you can do nothing if you can't) + protected abstract void pasteFromPrimary(void delegate(in char[])); /// requests a paste from PRIMARY. we pass it a delegate that should accept the data + + abstract protected void requestExit(); /// the program is finished and the terminal emulator is requesting you to exit + + /// Signal the UI that some attention should be given, e.g. blink the taskbar or sound the bell. + /// The default is to ignore the demand by instantly acknowledging it - if you override this, do NOT call super(). + protected void demandAttention() { + attentionReceived(); + } + + /// After it demands attention, call this when the attention has been received + /// you may call it immediately to ignore the demand (the default) + public void attentionReceived() { + attentionDemanded = false; + } + + // I believe \033[50buffer[] and up are available for extensions everywhere. + // when keys are shifted, xterm sends them as \033[1;2F for example with end. but is this even sane? how would we do it with say, F5? + // apparently shifted F5 is ^[[15;2~ + // alt + f5 is ^[[15;3~ + // alt+shift+f5 is ^[[15;4~ + + private string pasteDataPending = null; + + protected void justRead() { + if(pasteDataPending.length) { + sendPasteData(pasteDataPending); + import core.thread; Thread.sleep(50.msecs); // hack to keep it from closing, broken pipe i think + } + } + + // my custom extension.... the data is the text content of the link, the identifier is some bits attached to the unit + public void sendHyperlinkData(scope const(dchar)[] data, uint identifier) { + if(bracketedHyperlinkMode) { + sendToApplication("\033[220~"); + + import std.conv; + // FIXME: that second 0 is a "command", like which menu option, which mouse button, etc. + sendToApplication(to!string(identifier) ~ ";0;" ~ to!string(data)); + + sendToApplication("\033[221~"); + } else { + // without bracketed hyperlink, it simulates a paste + import std.conv; + sendPasteData(to!string(data)); + } + } + + public void sendPasteData(scope const(char)[] data) { + //if(pasteDataPending.length) + //throw new Exception("paste data being discarded, wtf, shouldnt happen"); + + if(bracketedPasteMode) + sendToApplication("\033[200~"); + + version(use_libssh2) + enum MAX_PASTE_CHUNK = 4000; + else + enum MAX_PASTE_CHUNK = 1024 * 1024 * 10; + + if(data.length > MAX_PASTE_CHUNK) { + // need to chunk it in order to receive echos, etc, + // to avoid deadlocks + pasteDataPending = data[MAX_PASTE_CHUNK .. $].idup; + data = data[0 .. MAX_PASTE_CHUNK]; + } else { + pasteDataPending = null; + } + + if(data.length) + sendToApplication(data); + + if(bracketedPasteMode) + sendToApplication("\033[201~"); + } + + public string getSelectedText() { + return getPlainText(selectionStart, selectionEnd); + } + + bool dragging; + int lastDragX, lastDragY; + public bool sendMouseInputToApplication(int termX, int termY, MouseEventType type, MouseButton button, bool shift, bool ctrl, bool alt) { + if(termX < 0) + termX = 0; + if(termX >= screenWidth) + termX = screenWidth - 1; + if(termY < 0) + termY = 0; + if(termY >= screenHeight) + termY = screenHeight - 1; + + version(Windows) { + // I'm swapping these because my laptop doesn't have a middle button, + // and putty swaps them too by default so whatevs. + if(button == MouseButton.right) + button = MouseButton.middle; + else if(button == MouseButton.middle) + button = MouseButton.right; + } + + int baseEventCode() { + int b; + // lol the xterm mouse thing sucks like javascript! unbelievable + // it doesn't support two buttons at once... + if(button == MouseButton.left) + b = 0; + else if(button == MouseButton.right) + b = 2; + else if(button == MouseButton.middle) + b = 1; + else if(button == MouseButton.wheelUp) + b = 64 | 0; + else if(button == MouseButton.wheelDown) + b = 64 | 1; + else + b = 3; // none pressed or button released + + if(shift) + b |= 4; + if(ctrl) + b |= 16; + if(alt) // sending alt as meta + b |= 8; + + return b; + } + + + if(type == MouseEventType.buttonReleased) { + // X sends press and release on wheel events, but we certainly don't care about those + if(button == MouseButton.wheelUp || button == MouseButton.wheelDown) + return false; + + if(dragging) { + auto text = getSelectedText(); + if(text.length) { + copyToPrimary(text); + } else if(!mouseButtonReleaseTracking || shift) { + // hyperlink check + int idx = termY * screenWidth + termX; + auto screen = (alternateScreenActive ? alternateScreen : normalScreen); + + if(screen[idx].hyperlinkStatus & 0x01) { + // it is a link! need to find the beginning and the end + auto start = idx; + auto end = idx; + auto value = screen[idx].hyperlinkStatus; + while(start > 0 && screen[start].hyperlinkStatus == value) + start--; + if(screen[start].hyperlinkStatus != value) + start++; + while(end < screen.length && screen[end].hyperlinkStatus == value) + end++; + + uint number; + dchar[64] buffer; + foreach(i, ch; screen[start .. end]) { + if(i >= buffer.length) + break; + if(!ch.hasNonCharacterData) + buffer[i] = ch.ch; + if(i < 16) { + number |= (ch.hyperlinkBit ? 1 : 0) << i; + } + } + + sendHyperlinkData(buffer[0 .. end - start], number); + } + } + } + + dragging = false; + if(mouseButtonReleaseTracking) { + int b = baseEventCode; + b |= 3; // always send none / button released + ScopeBuffer!(char, 16) buffer; + buffer ~= "\033[M"; + buffer ~= cast(char) (b | 32); + buffer ~= cast(char) (termX+1 + 32); + buffer ~= cast(char) (termY+1 + 32); + sendToApplication(buffer[]); + } + } + + if(type == MouseEventType.motion) { + if(termX != lastDragX || termY != lastDragY) { + lastDragY = termY; + lastDragX = termX; + if(mouseMotionTracking || (mouseButtonMotionTracking && button)) { + int b = baseEventCode; + ScopeBuffer!(char, 16) buffer; + buffer ~= "\033[M"; + buffer ~= cast(char) ((b | 32) + 32); + buffer ~= cast(char) (termX+1 + 32); + buffer ~= cast(char) (termY+1 + 32); + sendToApplication(buffer[]); + } + + if(dragging) { + auto idx = termY * screenWidth + termX; + + // the no-longer-selected portion needs to be invalidated + int start, end; + if(idx > selectionEnd) { + start = selectionEnd; + end = idx; + } else { + start = idx; + end = selectionEnd; + } + if(start < 0 || end >= ((alternateScreenActive ? alternateScreen.length : normalScreen.length))) + return false; + + foreach(ref cell; (alternateScreenActive ? alternateScreen : normalScreen)[start .. end]) { + cell.invalidated = true; + cell.selected = false; + } + + selectionEnd = idx; + + // and the freshly selected portion needs to be invalidated + if(selectionStart > selectionEnd) { + start = selectionEnd; + end = selectionStart; + } else { + start = selectionStart; + end = selectionEnd; + } + foreach(ref cell; (alternateScreenActive ? alternateScreen : normalScreen)[start .. end]) { + cell.invalidated = true; + cell.selected = true; + } + + return true; + } + } + } + + if(type == MouseEventType.buttonPressed) { + // double click detection + import std.datetime; + static SysTime lastClickTime; + static int consecutiveClicks = 1; + + if(button != MouseButton.wheelUp && button != MouseButton.wheelDown) { + if(Clock.currTime() - lastClickTime < dur!"msecs"(350)) + consecutiveClicks++; + else + consecutiveClicks = 1; + + lastClickTime = Clock.currTime(); + } + // end dbl click + + if(!(shift) && mouseButtonTracking) { + int b = baseEventCode; + + int x = termX; + int y = termY; + x++; y++; // applications expect it to be one-based + + ScopeBuffer!(char, 16) buffer; + buffer ~= "\033[M"; + buffer ~= cast(char) (b | 32); + buffer ~= cast(char) (x + 32); + buffer ~= cast(char) (y + 32); + + sendToApplication(buffer[]); + } else { + if(button == MouseButton.middle) { + pasteFromPrimary(&sendPasteData); + } + + if(button == MouseButton.wheelUp) { + scrollback(alt ? 0 : (ctrl ? 10 : 1), alt ? -(ctrl ? 10 : 1) : 0); + return true; + } + if(button == MouseButton.wheelDown) { + scrollback(alt ? 0 : -(ctrl ? 10 : 1), alt ? (ctrl ? 10 : 1) : 0); + return true; + } + + if(button == MouseButton.left) { + // we invalidate the old selection since it should no longer be highlighted... + makeSelectionOffsetsSane(selectionStart, selectionEnd); + + auto activeScreen = (alternateScreenActive ? &alternateScreen : &normalScreen); + foreach(ref cell; (*activeScreen)[selectionStart .. selectionEnd]) { + cell.invalidated = true; + cell.selected = false; + } + + if(consecutiveClicks == 1) { + selectionStart = termY * screenWidth + termX; + selectionEnd = selectionStart; + } else if(consecutiveClicks == 2) { + selectionStart = termY * screenWidth + termX; + selectionEnd = selectionStart; + while(selectionStart > 0 && !isWordSeparator((*activeScreen)[selectionStart-1].ch)) { + selectionStart--; + } + + while(selectionEnd < (*activeScreen).length && !isWordSeparator((*activeScreen)[selectionEnd].ch)) { + selectionEnd++; + } + + } else if(consecutiveClicks == 3) { + selectionStart = termY * screenWidth; + selectionEnd = selectionStart + screenWidth; + } + dragging = true; + lastDragX = termX; + lastDragY = termY; + + // then invalidate the new selection as well since it should be highlighted + foreach(ref cell; (alternateScreenActive ? alternateScreen : normalScreen)[selectionStart .. selectionEnd]) { + cell.invalidated = true; + cell.selected = true; + } + + return true; + } + if(button == MouseButton.right) { + + int changed1; + int changed2; + + auto click = termY * screenWidth + termX; + if(click < selectionStart) { + auto oldSelectionStart = selectionStart; + selectionStart = click; + changed1 = selectionStart; + changed2 = oldSelectionStart; + } else if(click > selectionEnd) { + auto oldSelectionEnd = selectionEnd; + selectionEnd = click; + + changed1 = oldSelectionEnd; + changed2 = selectionEnd; + } + + foreach(ref cell; (alternateScreenActive ? alternateScreen : normalScreen)[changed1 .. changed2]) { + cell.invalidated = true; + cell.selected = true; + } + + auto text = getPlainText(selectionStart, selectionEnd); + if(text.length) { + copyToPrimary(text); + } + return true; + } + } + } + + return false; + } + + protected void returnToNormalScreen() { + alternateScreenActive = false; + + if(cueScrollback) { + showScrollbackOnScreen(normalScreen, 0, true, 0); + newLine(false); + cueScrollback = false; + } + + notifyScrollbarRelevant(true, true); + } + + protected void outputOccurred() { } + + private int selectionStart; // an offset into the screen buffer + private int selectionEnd; // ditto + + /// Send a non-character key sequence + public bool sendKeyToApplication(TerminalKey key, bool shift = false, bool alt = false, bool ctrl = false, bool windows = false) { + bool redrawRequired = false; + + if((!alternateScreenActive || scrollingBack) && key == TerminalKey.ScrollLock) { + toggleScrollLock(); + return true; + } + + // scrollback controls. Unlike xterm, I only want to do this on the normal screen, since alt screen + // doesn't have scrollback anyway. Thus the key will be forwarded to the application. + if((!alternateScreenActive || scrollingBack) && key == TerminalKey.PageUp && (shift || scrollLock)) { + scrollback(10); + return true; + } else if((!alternateScreenActive || scrollingBack) && key == TerminalKey.PageDown && (shift || scrollLock)) { + scrollback(-10); + return true; + } else if((!alternateScreenActive || scrollingBack) && key == TerminalKey.Left && (shift || scrollLock)) { + scrollback(0, ctrl ? -10 : -1); + return true; + } else if((!alternateScreenActive || scrollingBack) && key == TerminalKey.Right && (shift || scrollLock)) { + scrollback(0, ctrl ? 10 : 1); + return true; + } else if((!alternateScreenActive || scrollingBack) && key == TerminalKey.Up && (shift || scrollLock)) { + scrollback(ctrl ? 10 : 1); + return true; + } else if((!alternateScreenActive || scrollingBack) && key == TerminalKey.Down && (shift || scrollLock)) { + scrollback(ctrl ? -10 : -1); + return true; + } else if((!alternateScreenActive || scrollingBack)) { // && ev.key != Key.Shift && ev.key != Key.Shift_r) { + if(endScrollback()) + redrawRequired = true; + } + + + + void sendToApplicationModified(string s) { + bool anyModifier = shift || alt || ctrl || windows; + if(!anyModifier || applicationCursorKeys) + sendToApplication(s); // FIXME: applicationCursorKeys can still be shifted i think but meh + else { + ScopeBuffer!(char, 16) modifierNumber; + char otherModifier = 0; + if(shift && alt && ctrl) modifierNumber = "8"; + if(alt && ctrl && !shift) modifierNumber = "7"; + if(shift && ctrl && !alt) modifierNumber = "6"; + if(ctrl && !shift && !alt) modifierNumber = "5"; + if(shift && alt && !ctrl) modifierNumber = "4"; + if(alt && !shift && !ctrl) modifierNumber = "3"; + if(shift && !alt && !ctrl) modifierNumber = "2"; + // FIXME: meta and windows + // windows is an extension + if(windows) { + if(modifierNumber.length) + otherModifier = '2'; + else + modifierNumber = "20"; + /* // the below is what we're really doing + int mn = 0; + if(modifierNumber.length) + mn = modifierNumber[0] + '0'; + mn += 20; + */ + } + + string keyNumber; + char terminator; + + if(s[$-1] == '~') { + keyNumber = s[2 .. $-1]; + terminator = '~'; + } else { + keyNumber = "1"; + terminator = s[$ - 1]; + } + + ScopeBuffer!(char, 32) buffer; + buffer ~= "\033["; + buffer ~= keyNumber; + buffer ~= ";"; + if(otherModifier) + buffer ~= otherModifier; + buffer ~= modifierNumber[]; + buffer ~= terminator; + // the xterm style is last bit tell us what it is + sendToApplication(buffer[]); + } + } + + alias TerminalKey Key; + import std.stdio; + // writefln("Key: %x", cast(int) key); + final switch(key) { + case Key.Left: sendToApplicationModified(applicationCursorKeys ? "\033OD" : "\033[D"); break; + case Key.Up: sendToApplicationModified(applicationCursorKeys ? "\033OA" : "\033[A"); break; + case Key.Down: sendToApplicationModified(applicationCursorKeys ? "\033OB" : "\033[B"); break; + case Key.Right: sendToApplicationModified(applicationCursorKeys ? "\033OC" : "\033[C"); break; + + case Key.Home: sendToApplicationModified(applicationCursorKeys ? "\033OH" : (1 ? "\033[H" : "\033[1~")); break; + case Key.Insert: sendToApplicationModified("\033[2~"); break; + case Key.Delete: sendToApplicationModified("\033[3~"); break; + + // the 1? is xterm vs gnu screen. but i really want xterm compatibility. + case Key.End: sendToApplicationModified(applicationCursorKeys ? "\033OF" : (1 ? "\033[F" : "\033[4~")); break; + case Key.PageUp: sendToApplicationModified("\033[5~"); break; + case Key.PageDown: sendToApplicationModified("\033[6~"); break; + + // the first one here is preferred, the second option is what xterm does if you turn on the "old function keys" option, which most apps don't actually expect + case Key.F1: sendToApplicationModified(1 ? "\033OP" : "\033[11~"); break; + case Key.F2: sendToApplicationModified(1 ? "\033OQ" : "\033[12~"); break; + case Key.F3: sendToApplicationModified(1 ? "\033OR" : "\033[13~"); break; + case Key.F4: sendToApplicationModified(1 ? "\033OS" : "\033[14~"); break; + case Key.F5: sendToApplicationModified("\033[15~"); break; + case Key.F6: sendToApplicationModified("\033[17~"); break; + case Key.F7: sendToApplicationModified("\033[18~"); break; + case Key.F8: sendToApplicationModified("\033[19~"); break; + case Key.F9: sendToApplicationModified("\033[20~"); break; + case Key.F10: sendToApplicationModified("\033[21~"); break; + case Key.F11: sendToApplicationModified("\033[23~"); break; + case Key.F12: sendToApplicationModified("\033[24~"); break; + + case Key.Escape: sendToApplicationModified("\033"); break; + + // my extensions + case Key.ScrollLock: sendToApplicationModified("\033[70~"); break; + + // see terminal.d for the other side of this + case cast(TerminalKey) '\n': sendToApplicationModified("\033[83~"); break; + case cast(TerminalKey) '\b': sendToApplicationModified("\033[78~"); break; + case cast(TerminalKey) '\t': sendToApplicationModified("\033[79~"); break; + } + + return redrawRequired; + } + + /// if a binary extension is triggered, the implementing class is responsible for figuring out how it should be made to fit into the screen buffer + protected /*abstract*/ BrokenUpImage handleBinaryExtensionData(const(ubyte)[]) { + return BrokenUpImage(); + } + + /// If you subclass this and return true, you can scroll on command without needing to redraw the entire screen; + /// returning true here suppresses the automatic invalidation of scrolled lines (except the new one). + protected bool scrollLines(int howMany, bool scrollUp) { + return false; + } + + // might be worth doing the redraw magic in here too. + // FIXME: not implemented + @disable protected void drawTextSection(int x, int y, TextAttributes attributes, in dchar[] text, bool isAllSpaces) { + // if you implement this it will always give you a continuous block on a single line. note that text may be a bunch of spaces, in that case you can just draw the bg color to clear the area + // or you can redraw based on the invalidated flag on the buffer + } + // FIXME: what about image sections? maybe it is still necessary to loop through them + + /// Style of the cursor + enum CursorStyle { + block, /// a solid block over the position (like default xterm or many gui replace modes) + underline, /// underlining the position (like the vga text mode default) + bar, /// a bar on the left side of the cursor position (like gui insert modes) + } + + // these can be overridden, but don't have to be + TextAttributes defaultTextAttributes() { + TextAttributes ta; + + ta.foregroundIndex = 256; // terminal.d uses this as Color.DEFAULT + ta.backgroundIndex = 256; + + import std.process; + // I'm using the environment for this because my programs and scripts + // already know this variable and then it gets nicely inherited. It is + // also easy to set without buggering with other arguments. So works for me. + version(with_24_bit_color) { + if(environment.get("ELVISBG") == "dark") { + ta.foreground = Color.white; + ta.background = Color.black; + } else { + ta.foreground = Color.black; + ta.background = Color.white; + } + } + + return ta; + } + + Color defaultForeground; + Color defaultBackground; + + Color[256] palette; + + /// . + static struct TextAttributes { + align(1): + bool bold() { return (attrStore & 1) ? true : false; } /// + void bold(bool t) { attrStore &= ~1; if(t) attrStore |= 1; } /// + + bool blink() { return (attrStore & 2) ? true : false; } /// + void blink(bool t) { attrStore &= ~2; if(t) attrStore |= 2; } /// + + bool invisible() { return (attrStore & 4) ? true : false; } /// + void invisible(bool t) { attrStore &= ~4; if(t) attrStore |= 4; } /// + + bool inverse() { return (attrStore & 8) ? true : false; } /// + void inverse(bool t) { attrStore &= ~8; if(t) attrStore |= 8; } /// + + bool underlined() { return (attrStore & 16) ? true : false; } /// + void underlined(bool t) { attrStore &= ~16; if(t) attrStore |= 16; } /// + + bool italic() { return (attrStore & 32) ? true : false; } /// + void italic(bool t) { attrStore &= ~32; if(t) attrStore |= 32; } /// + + bool strikeout() { return (attrStore & 64) ? true : false; } /// + void strikeout(bool t) { attrStore &= ~64; if(t) attrStore |= 64; } /// + + bool faint() { return (attrStore & 128) ? true : false; } /// + void faint(bool t) { attrStore &= ~128; if(t) attrStore |= 128; } /// + + // if the high bit here is set, you should use the full Color values if possible, and the value here sans the high bit if not + + bool foregroundIsDefault() { return (attrStore & 256) ? true : false; } /// + void foregroundIsDefault(bool t) { attrStore &= ~256; if(t) attrStore |= 256; } /// + + bool backgroundIsDefault() { return (attrStore & 512) ? true : false; } /// + void backgroundIsDefault(bool t) { attrStore &= ~512; if(t) attrStore |= 512; } /// + + // I am doing all this to get the store a bit smaller but + // I could go back to just plain `ushort foregroundIndex` etc. + + /// + @property ushort foregroundIndex() { + if(foregroundIsDefault) + return 256; + else + return foregroundIndexStore; + } + /// + @property ushort backgroundIndex() { + if(backgroundIsDefault) + return 256; + else + return backgroundIndexStore; + } + /// + @property void foregroundIndex(ushort v) { + if(v == 256) + foregroundIsDefault = true; + else + foregroundIsDefault = false; + foregroundIndexStore = cast(ubyte) v; + } + /// + @property void backgroundIndex(ushort v) { + if(v == 256) + backgroundIsDefault = true; + else + backgroundIsDefault = false; + backgroundIndexStore = cast(ubyte) v; + } + + ubyte foregroundIndexStore; /// the internal storage + ubyte backgroundIndexStore; /// ditto + ushort attrStore = 0; /// ditto + + version(with_24_bit_color) { + Color foreground; /// ditto + Color background; /// ditto + } + } + + //pragma(msg, TerminalCell.sizeof); + /// represents one terminal cell + align((void*).sizeof) + static struct TerminalCell { + align(1): + private union { + struct { + dchar chStore = ' '; /// the character + TextAttributes attributesStore; /// color, etc. + } + NonCharacterData nonCharacterDataStore; /// iff hasNonCharacterData + } + + dchar ch() { + assert(!hasNonCharacterData); + return chStore; + } + void ch(dchar c) { + hasNonCharacterData = false; + chStore = c; + } + ref TextAttributes attributes() { + assert(!hasNonCharacterData); + return attributesStore; + } + NonCharacterData nonCharacterData() { + assert(hasNonCharacterData); + return nonCharacterDataStore; + } + void nonCharacterData(NonCharacterData c) { + hasNonCharacterData = true; + nonCharacterDataStore = c; + } + + // bits: RRHLLNSI + // R = reserved, H = hyperlink ID bit, L = link, N = non-character data, S = selected, I = invalidated + ubyte attrStore = 1; // just invalidated to start + + bool invalidated() { return (attrStore & 1) ? true : false; } /// if it needs to be redrawn + void invalidated(bool t) { attrStore &= ~1; if(t) attrStore |= 1; } /// ditto + + bool selected() { return (attrStore & 2) ? true : false; } /// if it is currently selected by the user (for being copied to the clipboard) + void selected(bool t) { attrStore &= ~2; if(t) attrStore |= 2; } /// ditto + + bool hasNonCharacterData() { return (attrStore & 4) ? true : false; } /// + void hasNonCharacterData(bool t) { attrStore &= ~4; if(t) attrStore |= 4; } + + // 0 means it is not a hyperlink. Otherwise, it just alternates between 1 and 3 to tell adjacent links apart. + // value of 2 is reserved for future use. + ubyte hyperlinkStatus() { return (attrStore & 0b11000) >> 3; } + void hyperlinkStatus(ubyte t) { assert(t < 4); attrStore &= ~0b11000; attrStore |= t << 3; } + + bool hyperlinkBit() { return (attrStore & 0b100000) >> 5; } + void hyperlinkBit(bool t) { (attrStore &= ~0b100000); if(t) attrStore |= 0b100000; } + } + + bool hyperlinkFlipper; + bool hyperlinkActive; + int hyperlinkNumber; + + /// Cursor position, zero based. (0,0) == upper left. (0, 1) == second row, first column. + static struct CursorPosition { + int x; /// . + int y; /// . + alias y row; + alias x column; + } + + // these public functions can be used to manipulate the terminal + + /// clear the screen + void cls() { + TerminalCell plain; + plain.ch = ' '; + plain.attributes = currentAttributes; + plain.invalidated = true; + foreach(i, ref cell; alternateScreenActive ? alternateScreen : normalScreen) { + cell = plain; + } + } + + void makeSelectionOffsetsSane(ref int offsetStart, ref int offsetEnd) { + auto buffer = &alternateScreen; + + if(offsetStart < 0) + offsetStart = 0; + if(offsetEnd < 0) + offsetEnd = 0; + if(offsetStart > (*buffer).length) + offsetStart = cast(int) (*buffer).length; + if(offsetEnd > (*buffer).length) + offsetEnd = cast(int) (*buffer).length; + + // if it is backwards, we can flip it + if(offsetEnd < offsetStart) { + auto tmp = offsetStart; + offsetStart = offsetEnd; + offsetEnd = tmp; + } + } + + public string getPlainText(int offsetStart, int offsetEnd) { + auto buffer = alternateScreenActive ? &alternateScreen : &normalScreen; + + makeSelectionOffsetsSane(offsetStart, offsetEnd); + + if(offsetStart == offsetEnd) + return null; + + int x = offsetStart % screenWidth; + int firstSpace = -1; + string ret; + foreach(cell; (*buffer)[offsetStart .. offsetEnd]) { + if(cell.hasNonCharacterData) + break; + ret ~= cell.ch; + + x++; + if(x == screenWidth) { + x = 0; + if(firstSpace != -1) { + // we ended with a bunch of spaces, let's replace them with a single newline so the next is more natural + ret = ret[0 .. firstSpace]; + ret ~= "\n"; + firstSpace = -1; + } + } else { + if(cell.ch == ' ' && firstSpace == -1) + firstSpace = cast(int) ret.length - 1; + else if(cell.ch != ' ') + firstSpace = -1; + } + } + if(firstSpace != -1) { + bool allSpaces = true; + foreach(item; ret[firstSpace .. $]) { + if(item != ' ') { + allSpaces = false; + break; + } + } + + if(allSpaces) + ret = ret[0 .. firstSpace]; + } + + return ret; + } + + void scrollDown(int count = 1) { + if(cursorY + 1 < screenHeight) { + TerminalCell plain; + plain.ch = ' '; + plain.attributes = defaultTextAttributes(); + plain.invalidated = true; + foreach(i; 0 .. count) { + // FIXME: should that be cursorY or scrollZoneTop? + for(int y = scrollZoneBottom; y > cursorY; y--) + foreach(x; 0 .. screenWidth) { + ASS[y][x] = ASS[y - 1][x]; + ASS[y][x].invalidated = true; + } + + foreach(x; 0 .. screenWidth) + ASS[cursorY][x] = plain; + } + } + } + + void scrollUp(int count = 1) { + if(cursorY + 1 < screenHeight) { + TerminalCell plain; + plain.ch = ' '; + plain.attributes = defaultTextAttributes(); + plain.invalidated = true; + foreach(i; 0 .. count) { + // FIXME: should that be cursorY or scrollZoneBottom? + for(int y = scrollZoneTop; y < cursorY; y++) + foreach(x; 0 .. screenWidth) { + ASS[y][x] = ASS[y + 1][x]; + ASS[y][x].invalidated = true; + } + + foreach(x; 0 .. screenWidth) + ASS[cursorY][x] = plain; + } + } + } + + + int readingExtensionData = -1; + string extensionData; + + immutable(dchar[dchar])* characterSet = null; // null means use regular UTF-8 + + bool readingEsc = false; + ScopeBuffer!(ubyte, 1024) esc; + /// sends raw input data to the terminal as if the application printf()'d it or it echoed or whatever + void sendRawInput(in ubyte[] datain) { + const(ubyte)[] data = datain; + //import std.array; + //assert(!readingEsc, replace(cast(string) esc, "\033", "\\")); + again: + foreach(didx, b; data) { + if(readingExtensionData >= 0) { + if(readingExtensionData == extensionMagicIdentifier.length) { + if(b) { + switch(b) { + case 13, 10: + // ignore + break; + case 'A': .. case 'Z': + case 'a': .. case 'z': + case '0': .. case '9': + case '=': + case '+', '/': + case '_', '-': + // base64 ok + extensionData ~= b; + break; + default: + // others should abort the read + readingExtensionData = -1; + } + } else { + readingExtensionData = -1; + import std.base64; + auto got = handleBinaryExtensionData(Base64.decode(extensionData)); + + auto rep = got.representation; + foreach(y; 0 .. got.height) { + foreach(x; 0 .. got.width) { + addOutput(rep[0]); + rep = rep[1 .. $]; + } + newLine(true); + } + } + } else { + if(b == extensionMagicIdentifier[readingExtensionData]) + readingExtensionData++; + else { + // put the data back into the buffer, if possible + // (if the data was split across two packets, this may + // not be possible. but in that case, meh.) + if(cast(int) didx - cast(int) readingExtensionData >= 0) + data = data[didx - readingExtensionData .. $]; + readingExtensionData = -1; + goto again; + } + } + + continue; + } + + if(b == 0) { + readingExtensionData = 0; + extensionData = null; + continue; + } + + if(readingEsc) { + if(b == 27) { + // an esc in the middle of a sequence will + // cancel the first one + esc = null; + continue; + } + + if(b == 10) { + readingEsc = false; + } + esc ~= b; + + if(esc.length == 1 && esc[0] == '7') { + pushSavedCursor(cursorPosition); + esc = null; + readingEsc = false; + } else if(esc.length == 1 && esc[0] == 'M') { + // reverse index + esc = null; + readingEsc = false; + if(cursorY <= scrollZoneTop) + scrollDown(); + else + cursorY = cursorY - 1; + } else if(esc.length == 1 && esc[0] == '=') { + // application keypad + esc = null; + readingEsc = false; + } else if(esc.length == 2 && esc[0] == '%' && esc[1] == 'G') { + // UTF-8 mode + esc = null; + readingEsc = false; + } else if(esc.length == 1 && esc[0] == '8') { + cursorPosition = popSavedCursor; + esc = null; + readingEsc = false; + } else if(esc.length == 1 && esc[0] == 'c') { + // reset + // FIXME + esc = null; + readingEsc = false; + } else if(esc.length == 1 && esc[0] == '>') { + // normal keypad + esc = null; + readingEsc = false; + } else if(esc.length > 1 && ( + (esc[0] == '[' && (b >= 64 && b <= 126)) || + (esc[0] == ']' && b == '\007'))) + { + tryEsc(esc[]); + esc = null; + readingEsc = false; + } else if(esc.length == 3 && esc[0] == '%' && esc[1] == 'G') { + // UTF-8 mode. ignored because we're always in utf-8 mode (though should we be?) + esc = null; + readingEsc = false; + } else if(esc.length == 2 && esc[0] == ')') { + // more character set selection. idk exactly how this works + esc = null; + readingEsc = false; + } else if(esc.length == 2 && esc[0] == '(') { + // xterm command for character set + // FIXME: handling esc[1] == '0' would be pretty boss + // and esc[1] == 'B' == united states + if(esc[1] == '0') + characterSet = &lineDrawingCharacterSet; + else + characterSet = null; // our default is UTF-8 and i don't care much about others anyway. + + esc = null; + readingEsc = false; + } else if(esc.length == 1 && esc[0] == 'Z') { + // identify terminal + sendToApplication(terminalIdCode); + } + continue; + } + + if(b == 27) { + readingEsc = true; + debug if(esc.isNull && esc.length) { + import std.stdio; writeln("discarding esc ", cast(string) esc[]); + } + esc = null; + continue; + } + + if(b == 13) { + cursorX = 0; + setTentativeScrollback(0); + continue; + } + + if(b == 7) { + soundBell(); + continue; + } + + if(b == 8) { + cursorX = cursorX - 1; + setTentativeScrollback(cursorX); + continue; + } + + if(b == 9) { + int howMany = 8 - (cursorX % 8); + // so apparently it is just supposed to move the cursor. + // it breaks mutt to output spaces + cursorX = cursorX + howMany; + + if(!alternateScreenActive) + foreach(i; 0 .. howMany) + addScrollbackOutput(' '); // FIXME: it would be nice to actually put a tab character there for copy/paste accuracy (ditto with newlines actually) + continue; + } + +// std.stdio.writeln("READ ", data[w]); + addOutput(b); + } + } + + + /// construct + this(int width, int height) { + // initialization + + import std.process; + if(environment.get("ELVISBG") == "dark") { + defaultForeground = Color.white; + defaultBackground = Color.black; + } else { + defaultForeground = Color.black; + defaultBackground = Color.white; + } + + currentAttributes = defaultTextAttributes(); + cursorColor = Color.white; + + palette[] = xtermPalette[]; + + resizeTerminal(width, height); + + // update the other thing + if(windowTitle.length == 0) + windowTitle = "Terminal Emulator"; + changeWindowTitle(windowTitle); + changeIconTitle(iconTitle); + changeTextAttributes(currentAttributes); + } + + + private { + TerminalCell[] scrollbackMainScreen; + bool scrollbackCursorShowing; + int scrollbackCursorX; + int scrollbackCursorY; + } + + protected { + bool scrollingBack; + + int currentScrollback; + int currentScrollbackX; + } + + // FIXME: if it is resized while scrolling back, stuff can get messed up + + private int scrollbackLength_; + private void scrollbackLength(int i) { + scrollbackLength_ = i; + } + + int scrollbackLength() { + return scrollbackLength_; + } + + private int scrollbackWidth_; + int scrollbackWidth() { + return scrollbackWidth_ > screenWidth ? scrollbackWidth_ : screenWidth; + } + + /* virtual */ void notifyScrollbackAdded() {} + /* virtual */ void notifyScrollbarRelevant(bool isRelevantHorizontally, bool isRelevantVertically) {} + /* virtual */ void notifyScrollbarPosition(int x, int y) {} + + // coordinates are for a scroll bar, where 0,0 is the beginning of history + void scrollbackTo(int x, int y) { + if(alternateScreenActive && !scrollingBack) + return; + + if(!scrollingBack) { + startScrollback(); + } + + if(y < 0) + y = 0; + if(x < 0) + x = 0; + + currentScrollbackX = x; + currentScrollback = scrollbackLength - y; + + if(currentScrollback < 0) + currentScrollback = 0; + if(currentScrollbackX < 0) + currentScrollbackX = 0; + + if(!scrollLock && currentScrollback == 0 && currentScrollbackX == 0) { + endScrollback(); + } else { + cls(); + showScrollbackOnScreen(alternateScreen, currentScrollback, false, currentScrollbackX); + } + } + + void scrollback(int delta, int deltaX = 0) { + if(alternateScreenActive && !scrollingBack) + return; + + if(!scrollingBack) { + if(delta <= 0 && deltaX == 0) + return; // it does nothing to scroll down when not scrolling back + startScrollback(); + } + currentScrollback += delta; + if(!scrollbackReflow && deltaX) { + currentScrollbackX += deltaX; + int max = scrollbackWidth - screenWidth; + if(max < 0) + max = 0; + if(currentScrollbackX > max) + currentScrollbackX = max; + if(currentScrollbackX < 0) + currentScrollbackX = 0; + } + + int max = cast(int) scrollbackBuffer.length - screenHeight; + if(scrollbackReflow && max < 0) { + foreach(line; scrollbackBuffer[]) + max += cast(int) line.length / screenWidth; + } + + if(max < 0) + max = 0; + + if(scrollbackReflow && currentScrollback > max) { + foreach(line; scrollbackBuffer[]) + max += cast(int) line.length / screenWidth; + } + + if(currentScrollback > max) + currentScrollback = max; + if(currentScrollback < 0) + currentScrollback = 0; + + if(!scrollLock && currentScrollback <= 0 && currentScrollbackX <= 0) + endScrollback(); + else { + cls(); + showScrollbackOnScreen(alternateScreen, currentScrollback, scrollbackReflow, currentScrollbackX); + notifyScrollbarPosition(currentScrollbackX, scrollbackLength - currentScrollback - screenHeight); + } + } + + private void startScrollback() { + if(scrollingBack) + return; + currentScrollback = 0; + currentScrollbackX = 0; + scrollingBack = true; + scrollbackCursorX = cursorX; + scrollbackCursorY = cursorY; + scrollbackCursorShowing = cursorShowing; + scrollbackMainScreen = alternateScreen.dup; + alternateScreenActive = true; + + cursorShowing = false; + } + + bool endScrollback() { + //if(scrollLock) + // return false; + if(!scrollingBack) + return false; + scrollingBack = false; + cursorX = scrollbackCursorX; + cursorY = scrollbackCursorY; + cursorShowing = scrollbackCursorShowing; + alternateScreen = scrollbackMainScreen; + alternateScreenActive = false; + + currentScrollback = 0; + currentScrollbackX = 0; + + if(!scrollLock) { + scrollbackReflow = true; + recalculateScrollbackLength(); + } + + notifyScrollbarPosition(0, int.max); + + return true; + } + + private bool scrollbackReflow = true; + /* deprecated? */ + public void toggleScrollbackWrap() { + scrollbackReflow = !scrollbackReflow; + recalculateScrollbackLength(); + } + + private bool scrollLock = false; + public void toggleScrollLock() { + scrollLock = !scrollLock; + scrollbackReflow = !scrollLock; + recalculateScrollbackLength(); + + if(scrollLock) { + startScrollback(); + + cls(); + currentScrollback = 0; + currentScrollbackX = 0; + showScrollbackOnScreen(alternateScreen, currentScrollback, scrollbackReflow, currentScrollbackX); + notifyScrollbarPosition(currentScrollbackX, scrollbackLength - currentScrollback - screenHeight); + } else { + endScrollback(); + } + + //cls(); + //drawScrollback(); + } + + private void recalculateScrollbackLength() { + int count = cast(int) scrollbackBuffer.length; + int max; + if(scrollbackReflow) { + foreach(line; scrollbackBuffer[]) { + count += cast(int) line.length / screenWidth; + } + } else { + foreach(line; scrollbackBuffer[]) { + if(line.length > max) + max = cast(int) line.length; + } + } + scrollbackWidth_ = max; + scrollbackLength = count; + notifyScrollbackAdded(); + notifyScrollbarPosition(currentScrollbackX, currentScrollback ? scrollbackLength - currentScrollback : int.max); + } + + public void writeScrollbackToFile(string filename) { + import std.stdio; + auto file = File(filename, "wt"); + foreach(line; scrollbackBuffer[]) { + foreach(c; line) + file.write(c.ch); // I hope this is buffered + file.writeln(); + } + } + + public void drawScrollback(bool useAltScreen = false) { + showScrollbackOnScreen(useAltScreen ? alternateScreen : normalScreen, 0, true, 0); + } + + private void showScrollbackOnScreen(ref TerminalCell[] screen, int howFar, bool reflow, int howFarX) { + int start; + + cursorX = 0; + cursorY = 0; + + int excess = 0; + + if(scrollbackReflow) { + int numLines; + int idx = cast(int) scrollbackBuffer.length - 1; + foreach_reverse(line; scrollbackBuffer[]) { + auto lineCount = 1 + line.length / screenWidth; + numLines += lineCount; + if(numLines >= (screenHeight + howFar)) { + start = cast(int) idx; + excess = numLines - (screenHeight + howFar); + break; + } + idx--; + } + } else { + auto termination = cast(int) scrollbackBuffer.length - howFar; + if(termination < 0) + termination = cast(int) scrollbackBuffer.length; + + start = termination - screenHeight; + if(start < 0) + start = 0; + } + + TerminalCell overflowCell; + overflowCell.ch = '\»'; + overflowCell.attributes.backgroundIndex = 3; + overflowCell.attributes.foregroundIndex = 0; + version(with_24_bit_color) { + overflowCell.attributes.foreground = Color(40, 40, 40); + overflowCell.attributes.background = Color.yellow; + } + + outer: foreach(line; scrollbackBuffer[start .. $]) { + if(excess) { + line = line[excess * screenWidth .. $]; + excess = 0; + } + + if(howFarX) { + if(howFarX <= line.length) + line = line[howFarX .. $]; + else + line = null; + } + + bool overflowed; + foreach(cell; line) { + cell.invalidated = true; + if(overflowed) { + screen[cursorY * screenWidth + cursorX] = overflowCell; + break; + } else { + screen[cursorY * screenWidth + cursorX] = cell; + } + + if(cursorX == screenWidth-1) { + if(scrollbackReflow) { + cursorX = 0; + if(cursorY + 1 == screenHeight) + break outer; + cursorY = cursorY + 1; + } else { + overflowed = true; + } + } else + cursorX = cursorX + 1; + } + if(cursorY + 1 == screenHeight) + break; + cursorY = cursorY + 1; + cursorX = 0; + } + + cursorX = 0; + } + + protected bool cueScrollback; + + public void resizeTerminal(int w, int h) { + if(w == screenWidth && h == screenHeight) + return; // we're already good, do nothing to avoid wasting time and possibly losing a line (bash doesn't seem to like being told it "resized" to the same size) + + // do i like this? + if(scrollLock) + toggleScrollLock(); + + endScrollback(); // FIXME: hack + + screenWidth = w; + screenHeight = h; + + normalScreen.length = screenWidth * screenHeight; + alternateScreen.length = screenWidth * screenHeight; + scrollZoneBottom = screenHeight - 1; + + // we need to make sure the state is sane all across the board, so first we'll clear everything... + TerminalCell plain; + plain.ch = ' '; + plain.attributes = defaultTextAttributes; + plain.invalidated = true; + normalScreen[] = plain; + alternateScreen[] = plain; + + // then, in normal mode, we'll redraw using the scrollback buffer + // + // if we're in the alternate screen though, keep it blank because + // while redrawing makes sense in theory, odds are the program in + // charge of the normal screen didn't get the resize signal. + if(!alternateScreenActive) + showScrollbackOnScreen(normalScreen, 0, true, 0); + else + cueScrollback = true; + // but in alternate mode, it is the application's responsibility + + // the property ensures these are within bounds so this set just forces that + cursorY = cursorY; + cursorX = cursorX; + + recalculateScrollbackLength(); + } + + private CursorPosition popSavedCursor() { + CursorPosition pos; + //import std.stdio; writeln("popped"); + if(savedCursors.length) { + pos = savedCursors[$-1]; + savedCursors = savedCursors[0 .. $-1]; + savedCursors.assumeSafeAppend(); // we never keep references elsewhere so might as well reuse the memory as much as we can + } + + // If the screen resized after this was saved, it might be restored to a bad amount, so we need to sanity test. + if(pos.x < 0) + pos.x = 0; + if(pos.y < 0) + pos.y = 0; + if(pos.x > screenWidth) + pos.x = screenWidth - 1; + if(pos.y > screenHeight) + pos.y = screenHeight - 1; + + return pos; + } + + private void pushSavedCursor(CursorPosition pos) { + //import std.stdio; writeln("pushed"); + savedCursors ~= pos; + } + + public void clearScrollbackHistory() { + if(scrollingBack) + endScrollback(); + scrollbackBuffer.clear(); + scrollbackLength_ = 0; + scrollbackWidth_ = 0; + + notifyScrollbackAdded(); + } + + public void moveCursor(int x, int y) { + cursorX = x; + cursorY = y; + } + + /* FIXME: i want these to be private */ + protected { + TextAttributes currentAttributes; + CursorPosition cursorPosition; + CursorPosition[] savedCursors; // a stack + CursorStyle cursorStyle; + Color cursorColor; + string windowTitle; + string iconTitle; + + bool attentionDemanded; + + IndexedImage windowIcon; + IndexedImage[] iconStack; + + string[] titleStack; + + bool bracketedPasteMode; + bool bracketedHyperlinkMode; + bool mouseButtonTracking; + private bool _mouseMotionTracking; + bool mouseButtonReleaseTracking; + bool mouseButtonMotionTracking; + + bool mouseMotionTracking() { + return _mouseMotionTracking; + } + + void mouseMotionTracking(bool b) { + _mouseMotionTracking = b; + } + + void allMouseTrackingOff() { + mouseMotionTracking = false; + mouseButtonTracking = false; + mouseButtonReleaseTracking = false; + mouseButtonMotionTracking = false; + } + + bool wraparoundMode = true; + + bool alternateScreenActive; + bool cursorShowing = true; + + bool reverseVideo; + bool applicationCursorKeys; + + bool scrollingEnabled = true; + int scrollZoneTop; + int scrollZoneBottom; + + int screenWidth; + int screenHeight; + // assert(alternateScreen.length = screenWidth * screenHeight); + TerminalCell[] alternateScreen; + TerminalCell[] normalScreen; + + // the lengths can be whatever + ScrollbackBuffer scrollbackBuffer; + + static struct ScrollbackBuffer { + TerminalCell[][] backing; + + enum maxScrollback = 8192 / 2; // as a power of 2, i hope the compiler optimizes the % below to a simple bit mask... + + int start; + int length_; + + size_t length() { + return length_; + } + + void clear() { + start = 0; + length_ = 0; + backing = null; + } + + // FIXME: if scrollback hits limits the scroll bar needs + // to understand the circular buffer + + void opOpAssign(string op : "~")(TerminalCell[] line) { + if(length_ < maxScrollback) { + backing.assumeSafeAppend(); + backing ~= line; + length_++; + } else { + backing[start] = line; + start++; + if(start == maxScrollback) + start = 0; + } + } + + /* + int opApply(scope int delegate(ref TerminalCell[]) dg) { + foreach(ref l; backing) + if(auto res = dg(l)) + return res; + return 0; + } + + int opApplyReverse(scope int delegate(size_t, ref TerminalCell[]) dg) { + foreach_reverse(idx, ref l; backing) + if(auto res = dg(idx, l)) + return res; + return 0; + } + */ + + TerminalCell[] opIndex(int idx) { + return backing[(start + idx) % maxScrollback]; + } + + ScrollbackBufferRange opSlice(int startOfIteration, Dollar end) { + return ScrollbackBufferRange(&this, startOfIteration); + } + ScrollbackBufferRange opSlice() { + return ScrollbackBufferRange(&this, 0); + } + + static struct ScrollbackBufferRange { + ScrollbackBuffer* item; + int position; + int remaining; + this(ScrollbackBuffer* item, int startOfIteration) { + this.item = item; + position = startOfIteration; + remaining = cast(int) item.length - startOfIteration; + + } + + TerminalCell[] front() { return (*item)[position]; } + bool empty() { return remaining <= 0; } + void popFront() { + position++; + remaining--; + } + + TerminalCell[] back() { return (*item)[remaining - 1 - position]; } + void popBack() { + remaining--; + } + } + + static struct Dollar {}; + Dollar opDollar() { return Dollar(); } + + } + + struct Helper2 { + size_t row; + TerminalEmulator t; + this(TerminalEmulator t, size_t row) { + this.t = t; + this.row = row; + } + + ref TerminalCell opIndex(size_t cell) { + auto thing = t.alternateScreenActive ? &(t.alternateScreen) : &(t.normalScreen); + return (*thing)[row * t.screenWidth + cell]; + } + } + + struct Helper { + TerminalEmulator t; + this(TerminalEmulator t) { + this.t = t; + } + + Helper2 opIndex(size_t row) { + return Helper2(t, row); + } + } + + @property Helper ASS() { + return Helper(this); + } + + @property int cursorX() { return cursorPosition.x; } + @property int cursorY() { return cursorPosition.y; } + @property void cursorX(int x) { + if(x < 0) + x = 0; + if(x >= screenWidth) + x = screenWidth - 1; + cursorPosition.x = x; + } + @property void cursorY(int y) { + if(y < 0) + y = 0; + if(y >= screenHeight) + y = screenHeight - 1; + cursorPosition.y = y; + } + + void addOutput(string b) { + foreach(c; b) + addOutput(c); + } + + TerminalCell[] currentScrollbackLine; + ubyte[6] utf8SequenceBuffer; + int utf8SequenceBufferPosition; + // int scrollbackWrappingAt = 0; + dchar utf8Sequence; + int utf8BytesRemaining; + int currentUtf8Shift; + bool newLineOnNext; + void addOutput(ubyte b) { + + void addChar(dchar c) { + if(newLineOnNext) { + newLineOnNext = false; + // only if we're still on the right side... + if(cursorX == screenWidth - 1) + newLine(false); + } + TerminalCell tc; + + if(characterSet !is null) { + if(auto replacement = utf8Sequence in *characterSet) + utf8Sequence = *replacement; + } + tc.ch = utf8Sequence; + tc.attributes = currentAttributes; + tc.invalidated = true; + + if(hyperlinkActive) { + tc.hyperlinkStatus = hyperlinkFlipper ? 3 : 1; + tc.hyperlinkBit = hyperlinkNumber & 0x01; + hyperlinkNumber >>= 1; + } + + addOutput(tc); + } + + + // this takes in bytes at a time, but since the input encoding is assumed to be UTF-8, we need to gather the bytes + if(utf8BytesRemaining == 0) { + // we're at the beginning of a sequence + utf8Sequence = 0; + if(b < 128) { + utf8Sequence = cast(dchar) b; + // one byte thing, do nothing more... + } else { + // the number of bytes in the sequence is the number of set bits in the first byte... + uint shifted = 0; + bool there = false; + ubyte checkingBit = 7; + while(checkingBit) { + if(!there && b & (1 << checkingBit)) + utf8BytesRemaining++; + else + there = true; + if(there) + shifted |= b & (1 << checkingBit); + checkingBit--; + } + utf8BytesRemaining--; // since this current byte counts too + currentUtf8Shift = utf8BytesRemaining * 6; + + shifted <<= (currentUtf8Shift + checkingBit); + utf8Sequence = cast(dchar) shifted; + + utf8SequenceBufferPosition = 0; + utf8SequenceBuffer[utf8SequenceBufferPosition++] = b; + } + } else { + // add this to the byte we're doing right now... + utf8BytesRemaining--; + currentUtf8Shift -= 6; + if((b & 0b11000000) != 0b10000000) { + // invalid utf-8 sequence, + // discard it and try to continue + utf8BytesRemaining = 0; + utf8Sequence = 0xfffd; + foreach(i; 0 .. utf8SequenceBufferPosition) + addChar(utf8Sequence); // put out replacement char for everything in there so far + utf8SequenceBufferPosition = 0; + addOutput(b); // retry sending this byte as a new sequence after abandoning the old crap + return; + } + uint shifted = b; + shifted &= 0b00111111; + shifted <<= currentUtf8Shift; + utf8Sequence |= shifted; + + if(utf8SequenceBufferPosition < utf8SequenceBuffer.length) + utf8SequenceBuffer[utf8SequenceBufferPosition++] = b; + } + + if(utf8BytesRemaining) + return; // not enough data yet, wait for more before displaying anything + + if(utf8Sequence == 10) { + newLineOnNext = false; + auto cx = cursorX; // FIXME: this cx thing is a hack, newLine should prolly just do the right thing + + /* + TerminalCell tc; + tc.ch = utf8Sequence; + tc.attributes = currentAttributes; + tc.invalidated = true; + addOutput(tc); + */ + + newLine(true); + cursorX = cx; + } else { + addChar(utf8Sequence); + } + } + + private int recalculationThreshold = 0; + public void addScrollbackLine(TerminalCell[] line) { + scrollbackBuffer ~= line; + + if(scrollbackBuffer.length_ == ScrollbackBuffer.maxScrollback) { + recalculationThreshold++; + if(recalculationThreshold > 100) { + recalculateScrollbackLength(); + notifyScrollbackAdded(); + recalculationThreshold = 0; + } + } else { + if(!scrollbackReflow && line.length > scrollbackWidth_) + scrollbackWidth_ = cast(int) line.length; + scrollbackLength = cast(int) (scrollbackLength + 1 + (scrollbackBuffer[cast(int) scrollbackBuffer.length - 1].length) / screenWidth); + notifyScrollbackAdded(); + } + + if(!alternateScreenActive) + notifyScrollbarPosition(0, int.max); + } + + protected int maxScrollbackLength() pure const @nogc nothrow { + return 1024; + } + + bool insertMode = false; + void newLine(bool commitScrollback) { + if(!alternateScreenActive && commitScrollback) { + // I am limiting this because obscenely long lines are kinda useless anyway and + // i don't want it to eat excessive memory when i spam some thing accidentally + if(currentScrollbackLine.length < maxScrollbackLength()) + addScrollbackLine(currentScrollbackLine.sliceTrailingWhitespace); + else + addScrollbackLine(currentScrollbackLine[0 .. maxScrollbackLength()].sliceTrailingWhitespace); + + currentScrollbackLine = null; + currentScrollbackLine.reserve(64); + // scrollbackWrappingAt = 0; + } + + cursorX = 0; + if(scrollingEnabled && cursorY >= scrollZoneBottom) { + size_t idx = scrollZoneTop * screenWidth; + + // When we scroll up, we need to update the selection position too + if(selectionStart != selectionEnd) { + selectionStart -= screenWidth; + selectionEnd -= screenWidth; + } + foreach(l; scrollZoneTop .. scrollZoneBottom) { + if(alternateScreenActive) { + if(idx + screenWidth * 2 > alternateScreen.length) + break; + alternateScreen[idx .. idx + screenWidth] = alternateScreen[idx + screenWidth .. idx + screenWidth * 2]; + } else { + if(idx + screenWidth * 2 > normalScreen.length) + break; + normalScreen[idx .. idx + screenWidth] = normalScreen[idx + screenWidth .. idx + screenWidth * 2]; + } + idx += screenWidth; + } + /* + foreach(i; 0 .. screenWidth) { + if(alternateScreenActive) { + alternateScreen[idx] = alternateScreen[idx + screenWidth]; + alternateScreen[idx].invalidated = true; + } else { + normalScreen[idx] = normalScreen[idx + screenWidth]; + normalScreen[idx].invalidated = true; + } + idx++; + } + */ + /* + foreach(i; 0 .. screenWidth) { + if(alternateScreenActive) { + alternateScreen[idx].ch = ' '; + alternateScreen[idx].attributes = currentAttributes; + alternateScreen[idx].invalidated = true; + } else { + normalScreen[idx].ch = ' '; + normalScreen[idx].attributes = currentAttributes; + normalScreen[idx].invalidated = true; + } + idx++; + } + */ + + TerminalCell plain; + plain.ch = ' '; + plain.attributes = currentAttributes; + if(alternateScreenActive) { + alternateScreen[idx .. idx + screenWidth] = plain; + } else { + normalScreen[idx .. idx + screenWidth] = plain; + } + } else { + if(insertMode) { + scrollDown(); + } else + cursorY = cursorY + 1; + } + + invalidateAll = true; + } + + protected bool invalidateAll; + + void clearSelection() { + foreach(ref tc; alternateScreenActive ? alternateScreen : normalScreen) + if(tc.selected) { + tc.selected = false; + tc.invalidated = true; + } + selectionStart = 0; + selectionEnd = 0; + } + + private int tentativeScrollback = int.max; + private void setTentativeScrollback(int a) { + tentativeScrollback = a; + } + + void addScrollbackOutput(dchar ch) { + TerminalCell plain; + plain.ch = ch; + plain.attributes = currentAttributes; + addScrollbackOutput(plain); + } + + void addScrollbackOutput(TerminalCell tc) { + if(tentativeScrollback != int.max) { + if(tentativeScrollback >= 0 && tentativeScrollback < currentScrollbackLine.length) { + currentScrollbackLine = currentScrollbackLine[0 .. tentativeScrollback]; + currentScrollbackLine.assumeSafeAppend(); + } + tentativeScrollback = int.max; + } + + /* + TerminalCell plain; + plain.ch = ' '; + plain.attributes = currentAttributes; + int lol = cursorX + scrollbackWrappingAt; + while(lol >= currentScrollbackLine.length) + currentScrollbackLine ~= plain; + currentScrollbackLine[lol] = tc; + */ + + currentScrollbackLine ~= tc; + + } + + void addOutput(TerminalCell tc) { + if(alternateScreenActive) { + if(alternateScreen[cursorY * screenWidth + cursorX].selected) { + clearSelection(); + } + alternateScreen[cursorY * screenWidth + cursorX] = tc; + } else { + if(normalScreen[cursorY * screenWidth + cursorX].selected) { + clearSelection(); + } + // FIXME: make this more efficient if it is writing the same thing, + // then it need not be invalidated. Same with above for the alt screen + normalScreen[cursorY * screenWidth + cursorX] = tc; + + addScrollbackOutput(tc); + } + // FIXME: the wraparoundMode seems to help gnu screen but then it doesn't go away properly and that messes up bash... + //if(wraparoundMode && cursorX == screenWidth - 1) { + if(cursorX == screenWidth - 1) { + // FIXME: should this check the scrolling zone instead? + newLineOnNext = true; + + //if(!alternateScreenActive || cursorY < screenHeight - 1) + //newLine(false); + + // scrollbackWrappingAt = cast(int) currentScrollbackLine.length; + } else + cursorX = cursorX + 1; + + } + + void tryEsc(ubyte[] esc) { + bool[2] sidxProcessed; + int[][2] argsAtSidx; + int[12][2] argsAtSidxBuffer; + + int[12][4] argsBuffer; + int argsBufferLocation; + + int[] getArgsBase(int sidx, int[] defaults) { + assert(sidx == 1 || sidx == 2); + + if(sidxProcessed[sidx - 1]) { + int[] bfr = argsBuffer[argsBufferLocation++][]; + if(argsBufferLocation == argsBuffer.length) + argsBufferLocation = 0; + bfr[0 .. defaults.length] = defaults[]; + foreach(idx, v; argsAtSidx[sidx - 1]) + if(v != int.min) + bfr[idx] = v; + return bfr[0 .. max(argsAtSidx[sidx - 1].length, defaults.length)]; + } + + auto argsSection = cast(char[]) esc[sidx .. $-1]; + int[] args = argsAtSidxBuffer[sidx - 1][]; + + import std.string : split; + import std.conv : to; + int lastIdx = 0; + + foreach(i, arg; split(argsSection, ";")) { + int value; + if(arg.length) { + //import std.stdio; writeln(esc); + value = to!int(arg); + } else + value = int.min; // defaults[i]; + + if(args.length > i) + args[i] = value; + else + assert(0); + lastIdx++; + } + + argsAtSidx[sidx - 1] = args[0 .. lastIdx]; + sidxProcessed[sidx - 1] = true; + + return getArgsBase(sidx, defaults); + } + int[] getArgs(int[] defaults...) { + return getArgsBase(1, defaults); + } + + // FIXME + // from http://invisible-island.net/xterm/ctlseqs/ctlseqs.html + // check out this section: "Window manipulation (from dtterm, as well as extensions)" + // especially the title stack, that should rock + /* +P s = 2 2 ; 0 → Save xterm icon and window title on stack. +P s = 2 2 ; 1 → Save xterm icon title on stack. +P s = 2 2 ; 2 → Save xterm window title on stack. +P s = 2 3 ; 0 → Restore xterm icon and window title from stack. +P s = 2 3 ; 1 → Restore xterm icon title from stack. +P s = 2 3 ; 2 → Restore xterm window title from stack. + + */ + + if(esc[0] == ']' && esc.length > 1) { + int idx = -1; + foreach(i, e; esc) + if(e == ';') { + idx = cast(int) i; + break; + } + if(idx != -1) { + auto arg = cast(char[]) esc[idx + 1 .. $-1]; + switch(cast(char[]) esc[1..idx]) { + case "0": + // icon name and window title + windowTitle = iconTitle = arg.idup; + changeWindowTitle(windowTitle); + changeIconTitle(iconTitle); + break; + case "1": + // icon name + iconTitle = arg.idup; + changeIconTitle(iconTitle); + break; + case "2": + // window title + windowTitle = arg.idup; + changeWindowTitle(windowTitle); + break; + case "10": + // change default text foreground color + break; + case "11": + // change gui background color + break; + case "12": + if(arg.length) + arg = arg[1 ..$]; // skip past the thing + if(arg.length) { + cursorColor = Color.fromString(arg); + foreach(ref p; cursorColor.components[0 .. 3]) + p ^= 0xff; + } else + cursorColor = Color.white; + break; + case "50": + // change font + break; + case "52": + // copy/paste control + // echo -e "\033]52;p;?\007" + // the p == primary + // the data after it is either base64 stuff to copy or ? to request a paste + + if(arg == "p;?") { + // i'm using this to request a paste. not quite compatible with xterm, but kinda + // because xterm tends not to answer anyway. + pasteFromPrimary(&sendPasteData); + } else if(arg.length > 2 && arg[0 .. 2] == "p;") { + auto info = arg[2 .. $]; + try { + import std.base64; + auto data = Base64.decode(info); + copyToPrimary(cast(string) data); + } catch(Exception e) {} + } + + if(arg == "c;?") { + // i'm using this to request a paste. not quite compatible with xterm, but kinda + // because xterm tends not to answer anyway. + pasteFromClipboard(&sendPasteData); + } else if(arg.length > 2 && arg[0 .. 2] == "c;") { + auto info = arg[2 .. $]; + try { + import std.base64; + auto data = Base64.decode(info); + copyToClipboard(cast(string) data); + } catch(Exception e) {} + } + + break; + case "4": + // palette change or query + // set color #0 == black + // echo -e '\033]4;0;black\007' + /* + echo -e '\033]4;9;?\007' ; cat + + ^[]4;9;rgb:ffff/0000/0000^G + */ + + // FIXME: if the palette changes, we should redraw so the change is immediately visible (as if we were using a real palette) + break; + case "104": + // palette reset + // reset color #0 + // echo -e '\033[104;0\007' + break; + /* Extensions */ + case "5000": + // change window icon (send a base64 encoded image or something) + /* + The format here is width and height as a single char each + '0'-'9' == 0-9 + 'a'-'z' == 10 - 36 + anything else is invalid + + then a palette in hex rgba format (8 chars each), up to 26 entries + + then a capital Z + + if a palette entry == 'P', it means pull from the current palette (FIXME not implemented) + + then 256 characters between a-z (must be lowercase!) which are the palette entries for + the pixels, top to bottom, left to right, so the image is 16x16. if it ends early, the + rest of the data is assumed to be zero + + you can also do e.g. 22a, which means repeat a 22 times for some RLE. + + anything out of range aborts the operation + */ + auto img = readSmallTextImage(arg); + windowIcon = img; + changeWindowIcon(img); + break; + case "5001": + // demand attention + attentionDemanded = true; + demandAttention(); + break; + default: + unknownEscapeSequence("" ~ cast(char) esc[1]); + } + } + } else if(esc[0] == '[' && esc.length > 1) { + switch(esc[$-1]) { + case 'Z': + // CSI Ps Z Cursor Backward Tabulation Ps tab stops (default = 1) (CBT). + // FIXME? + break; + case 'n': + switch(esc[$-2]) { + import std.string; + // request status report, reply OK + case '5': sendToApplication("\033[0n"); break; + // request cursor position + case '6': sendToApplication(format("\033[%d;%dR", cursorY + 1, cursorX + 1)); break; + default: unknownEscapeSequence(cast(string) esc); + } + break; + case 'A': if(cursorY) cursorY = cursorY - getArgs(1)[0]; break; + case 'B': if(cursorY != this.screenHeight - 1) cursorY = cursorY + getArgs(1)[0]; break; + case 'D': if(cursorX) cursorX = cursorX - getArgs(1)[0]; setTentativeScrollback(cursorX); break; + case 'C': if(cursorX != this.screenWidth - 1) cursorX = cursorX + getArgs(1)[0]; break; + + case 'd': cursorY = getArgs(1)[0]-1; break; + + case 'E': cursorY = cursorY + getArgs(1)[0]; cursorX = 0; break; + case 'F': cursorY = cursorY - getArgs(1)[0]; cursorX = 0; break; + case 'G': cursorX = getArgs(1)[0] - 1; break; + case 'H': + auto got = getArgs(1, 1); + cursorX = got[1] - 1; + + if(got[0] - 1 == cursorY) + setTentativeScrollback(cursorX); + else + setTentativeScrollback(0); + + cursorY = got[0] - 1; + newLineOnNext = false; + break; + case 'L': + // insert lines + scrollDown(getArgs(1)[0]); + break; + case 'M': + // delete lines + if(cursorY + 1 < screenHeight) { + TerminalCell plain; + plain.ch = ' '; + plain.attributes = defaultTextAttributes(); + foreach(i; 0 .. getArgs(1)[0]) { + foreach(y; cursorY .. scrollZoneBottom) + foreach(x; 0 .. screenWidth) { + ASS[y][x] = ASS[y + 1][x]; + ASS[y][x].invalidated = true; + } + foreach(x; 0 .. screenWidth) { + ASS[scrollZoneBottom][x] = plain; + } + } + } + break; + case 'K': + auto arg = getArgs(0)[0]; + int start, end; + if(arg == 0) { + // clear from cursor to end of line + start = cursorX; + end = this.screenWidth; + } else if(arg == 1) { + // clear from cursor to beginning of line + start = 0; + end = cursorX + 1; + } else if(arg == 2) { + // clear entire line + start = 0; + end = this.screenWidth; + } + + TerminalCell plain; + plain.ch = ' '; + plain.attributes = currentAttributes; + + for(int i = start; i < end; i++) { + if(ASS[cursorY][i].selected) + clearSelection(); + ASS[cursorY] + [i] = plain; + } + break; + case 's': + pushSavedCursor(cursorPosition); + break; + case 'u': + cursorPosition = popSavedCursor(); + break; + case 'g': + auto arg = getArgs(0)[0]; + TerminalCell plain; + plain.ch = ' '; + plain.attributes = currentAttributes; + if(arg == 0) { + // clear current column + for(int i = 0; i < this.screenHeight; i++) + ASS[i] + [cursorY] = plain; + } else if(arg == 3) { + // clear all + cls(); + } + break; + case 'q': + // xterm also does blinks on the odd numbers (x-1) + if(esc == "[0 q") + cursorStyle = CursorStyle.block; // FIXME: restore default + if(esc == "[2 q") + cursorStyle = CursorStyle.block; + else if(esc == "[4 q") + cursorStyle = CursorStyle.underline; + else if(esc == "[6 q") + cursorStyle = CursorStyle.bar; + + changeCursorStyle(cursorStyle); + break; + case 't': + // window commands + // i might support more of these but for now i just want the stack stuff. + + auto args = getArgs(0, 0); + if(args[0] == 22) { + // save window title to stack + // xterm says args[1] should tell if it is the window title, the icon title, or both, but meh + titleStack ~= windowTitle; + iconStack ~= windowIcon; + } else if(args[0] == 23) { + // restore from stack + if(titleStack.length) { + windowTitle = titleStack[$ - 1]; + changeWindowTitle(titleStack[$ - 1]); + titleStack = titleStack[0 .. $ - 1]; + } + + if(iconStack.length) { + windowIcon = iconStack[$ - 1]; + changeWindowIcon(iconStack[$ - 1]); + iconStack = iconStack[0 .. $ - 1]; + } + } + break; + case 'm': + // FIXME used by xterm to decide whether to construct + // CSI > Pp ; Pv m CSI > Pp m Set/reset key modifier options, xterm. + if(esc[1] == '>') + goto default; + // done + argsLoop: foreach(argIdx, arg; getArgs(0)) + switch(arg) { + case 0: + // normal + currentAttributes = defaultTextAttributes; + break; + case 1: + currentAttributes.bold = true; + break; + case 2: + currentAttributes.faint = true; + break; + case 3: + currentAttributes.italic = true; + break; + case 4: + currentAttributes.underlined = true; + break; + case 5: + currentAttributes.blink = true; + break; + case 6: + // rapid blink, treating the same as regular blink + currentAttributes.blink = true; + break; + case 7: + currentAttributes.inverse = true; + break; + case 8: + currentAttributes.invisible = true; + break; + case 9: + currentAttributes.strikeout = true; + break; + case 10: + // primary font + break; + case 11: .. case 19: + // alternate fonts + break; + case 20: + // Fraktur font + break; + case 21: + // bold off and doubled underlined + break; + case 22: + currentAttributes.bold = false; + currentAttributes.faint = false; + break; + case 23: + currentAttributes.italic = false; + break; + case 24: + currentAttributes.underlined = false; + break; + case 25: + currentAttributes.blink = false; + break; + case 26: + // reserved + break; + case 27: + currentAttributes.inverse = false; + break; + case 28: + currentAttributes.invisible = false; + break; + case 29: + currentAttributes.strikeout = false; + break; + case 30: + .. + case 37: + // set foreground color + /* + Color nc; + ubyte multiplier = currentAttributes.bold ? 255 : 127; + nc.r = cast(ubyte)((arg - 30) & 1) * multiplier; + nc.g = cast(ubyte)(((arg - 30) & 2)>>1) * multiplier; + nc.b = cast(ubyte)(((arg - 30) & 4)>>2) * multiplier; + nc.a = 255; + */ + currentAttributes.foregroundIndex = cast(ubyte)(arg - 30); + version(with_24_bit_color) + currentAttributes.foreground = palette[arg-30 + (currentAttributes.bold ? 8 : 0)]; + break; + case 38: + // xterm 256 color set foreground color + auto args = getArgs()[argIdx + 1 .. $]; + if(args.length > 3 && args[0] == 2) { + // set color to closest match in palette. but since we have full support, we'll just take it directly + auto fg = Color(args[1], args[2], args[3]); + version(with_24_bit_color) + currentAttributes.foreground = fg; + // and try to find a low default palette entry for maximum compatibility + // 0x8000 == approximation + currentAttributes.foregroundIndex = 0x8000 | cast(ushort) findNearestColor(xtermPalette[0 .. 16], fg); + } else if(args.length > 1 && args[0] == 5) { + // set to palette index + version(with_24_bit_color) + currentAttributes.foreground = palette[args[1]]; + currentAttributes.foregroundIndex = cast(ushort) args[1]; + } + break argsLoop; + case 39: + // default foreground color + auto dflt = defaultTextAttributes(); + + version(with_24_bit_color) + currentAttributes.foreground = dflt.foreground; + currentAttributes.foregroundIndex = dflt.foregroundIndex; + break; + case 40: + .. + case 47: + // set background color + /* + Color nc; + nc.r = cast(ubyte)((arg - 40) & 1) * 255; + nc.g = cast(ubyte)(((arg - 40) & 2)>>1) * 255; + nc.b = cast(ubyte)(((arg - 40) & 4)>>2) * 255; + nc.a = 255; + */ + + currentAttributes.backgroundIndex = cast(ubyte)(arg - 40); + //currentAttributes.background = nc; + version(with_24_bit_color) + currentAttributes.background = palette[arg-40]; + break; + case 48: + // xterm 256 color set background color + auto args = getArgs()[argIdx + 1 .. $]; + if(args.length > 3 && args[0] == 2) { + // set color to closest match in palette. but since we have full support, we'll just take it directly + auto bg = Color(args[1], args[2], args[3]); + version(with_24_bit_color) + currentAttributes.background = Color(args[1], args[2], args[3]); + + // and try to find a low default palette entry for maximum compatibility + // 0x8000 == this is an approximation + currentAttributes.backgroundIndex = 0x8000 | cast(ushort) findNearestColor(xtermPalette[0 .. 8], bg); + } else if(args.length > 1 && args[0] == 5) { + // set to palette index + version(with_24_bit_color) + currentAttributes.background = palette[args[1]]; + currentAttributes.backgroundIndex = cast(ushort) args[1]; + } + + break argsLoop; + case 49: + // default background color + auto dflt = defaultTextAttributes(); + + version(with_24_bit_color) + currentAttributes.background = dflt.background; + currentAttributes.backgroundIndex = dflt.backgroundIndex; + break; + case 51: + // framed + break; + case 52: + // encircled + break; + case 53: + // overlined + break; + case 54: + // not framed or encircled + break; + case 55: + // not overlined + break; + case 90: .. case 97: + // high intensity foreground color + break; + case 100: .. case 107: + // high intensity background color + break; + default: + unknownEscapeSequence(cast(string) esc); + } + break; + case 'J': + // erase in display + auto arg = getArgs(0)[0]; + switch(arg) { + case 0: + TerminalCell plain; + plain.ch = ' '; + plain.attributes = currentAttributes; + // erase below + foreach(i; cursorY * screenWidth + cursorX .. screenWidth * screenHeight) { + if(alternateScreenActive) + alternateScreen[i] = plain; + else + normalScreen[i] = plain; + } + break; + case 1: + // erase above + unknownEscapeSequence("FIXME"); + break; + case 2: + // erase all + cls(); + break; + default: unknownEscapeSequence(cast(string) esc); + } + break; + case 'r': + if(esc[1] != '?') { + // set scrolling zone + // default should be full size of window + auto args = getArgs(1, screenHeight); + + // FIXME: these are supposed to be per-buffer + scrollZoneTop = args[0] - 1; + scrollZoneBottom = args[1] - 1; + } else { + // restore... something FIXME + } + break; + case 'h': + if(esc[1] != '?') + foreach(arg; getArgs()) + switch(arg) { + case 4: + insertMode = true; + break; + case 34: + // no idea. vim inside screen sends it + break; + default: unknownEscapeSequence(cast(string) esc); + } + else + //import std.stdio; writeln("h magic ", cast(string) esc); + foreach(arg; getArgsBase(2, null)) { + if(arg > 65535) { + /* Extensions */ + if(arg < 65536 + 65535) { + // activate hyperlink + hyperlinkFlipper = !hyperlinkFlipper; + hyperlinkActive = true; + hyperlinkNumber = arg - 65536; + } + } else + switch(arg) { + case 1: + // application cursor keys + applicationCursorKeys = true; + break; + case 3: + // 132 column mode + break; + case 4: + // smooth scroll + break; + case 5: + // reverse video + reverseVideo = true; + break; + case 6: + // origin mode + break; + case 7: + // wraparound mode + wraparoundMode = false; + // FIXME: wraparoundMode i think is supposed to be off by default but then bash doesn't work right so idk, this gives the best results + break; + case 9: + allMouseTrackingOff(); + mouseButtonTracking = true; + break; + case 12: + // start blinking cursor + break; + case 1034: + // meta keys???? + break; + case 1049: + // Save cursor as in DECSC and use Alternate Screen Buffer, clearing it first. + alternateScreenActive = true; + scrollLock = false; + pushSavedCursor(cursorPosition); + cls(); + notifyScrollbarRelevant(false, false); + break; + case 1000: + // send mouse X&Y on button press and release + allMouseTrackingOff(); + mouseButtonTracking = true; + mouseButtonReleaseTracking = true; + break; + case 1001: // hilight tracking, this is kinda weird so i don't think i want to implement it + break; + case 1002: + allMouseTrackingOff(); + mouseButtonTracking = true; + mouseButtonReleaseTracking = true; + mouseButtonMotionTracking = true; + // use cell motion mouse tracking + break; + case 1003: + // ALL motion is sent + allMouseTrackingOff(); + mouseButtonTracking = true; + mouseButtonReleaseTracking = true; + mouseMotionTracking = true; + break; + case 1005: + // enable utf-8 mouse mode + /* +UTF-8 (1005) + This enables UTF-8 encoding for Cx and Cy under all tracking + modes, expanding the maximum encodable position from 223 to + 2015. For positions less than 95, the resulting output is + identical under both modes. Under extended mouse mode, posi- + tions greater than 95 generate "extra" bytes which will con- + fuse applications which do not treat their input as a UTF-8 + stream. Likewise, Cb will be UTF-8 encoded, to reduce confu- + sion with wheel mouse events. + Under normal mouse mode, positions outside (160,94) result in + byte pairs which can be interpreted as a single UTF-8 charac- + ter; applications which do treat their input as UTF-8 will + almost certainly be confused unless extended mouse mode is + active. + This scheme has the drawback that the encoded coordinates will + not pass through luit unchanged, e.g., for locales using non- + UTF-8 encoding. + */ + break; + case 1006: + /* +SGR (1006) + The normal mouse response is altered to use CSI < followed by + semicolon-separated encoded button value, the Cx and Cy ordi- + nates and a final character which is M for button press and m + for button release. + o The encoded button value in this case does not add 32 since + that was useful only in the X10 scheme for ensuring that the + byte containing the button value is a printable code. + o The modifiers are encoded in the same way. + o A different final character is used for button release to + resolve the X10 ambiguity regarding which button was + released. + The highlight tracking responses are also modified to an SGR- + like format, using the same SGR-style scheme and button-encod- + ings. + */ + break; + case 1015: + /* +URXVT (1015) + The normal mouse response is altered to use CSI followed by + semicolon-separated encoded button value, the Cx and Cy ordi- + nates and final character M . + This uses the same button encoding as X10, but printing it as + a decimal integer rather than as a single byte. + However, CSI M can be mistaken for DL (delete lines), while + the highlight tracking CSI T can be mistaken for SD (scroll + down), and the Window manipulation controls. For these rea- + sons, the 1015 control is not recommended; it is not an + improvement over 1005. + */ + break; + case 1048: + pushSavedCursor(cursorPosition); + break; + case 2004: + bracketedPasteMode = true; + break; + case 3004: + bracketedHyperlinkMode = true; + break; + case 1047: + case 47: + alternateScreenActive = true; + scrollLock = false; + cls(); + notifyScrollbarRelevant(false, false); + break; + case 25: + cursorShowing = true; + break; + + /* Done */ + default: unknownEscapeSequence(cast(string) esc); + } + } + break; + case 'p': + // it is asking a question... and tbh i don't care. + break; + case 'l': + //import std.stdio; writeln("l magic ", cast(string) esc); + if(esc[1] != '?') + foreach(arg; getArgs()) + switch(arg) { + case 4: + insertMode = false; + break; + case 34: + // no idea. vim inside screen sends it + break; + case 1005: + // turn off utf-8 mouse + break; + case 1006: + // turn off sgr mouse + break; + case 1015: + // turn off urxvt mouse + break; + default: unknownEscapeSequence(cast(string) esc); + } + else + foreach(arg; getArgsBase(2, null)) { + if(arg > 65535) { + /* Extensions */ + if(arg < 65536 + 65535) + hyperlinkActive = false; + } + switch(arg) { + case 1: + // normal cursor keys + applicationCursorKeys = false; + break; + case 3: + // 80 column mode + break; + case 4: + // smooth scroll + break; + case 5: + // normal video + reverseVideo = false; + break; + case 6: + // normal cursor mode + break; + case 7: + // wraparound mode + wraparoundMode = true; + break; + case 12: + // stop blinking cursor + break; + case 1034: + // meta keys???? + break; + case 1049: + cursorPosition = popSavedCursor; + wraparoundMode = true; + + returnToNormalScreen(); + break; + case 1001: // hilight tracking, this is kinda weird so i don't think i want to implement it + break; + case 9: + case 1000: + case 1002: + case 1003: + allMouseTrackingOff(); + break; + case 1005: + case 1006: + // idk + break; + case 1048: + cursorPosition = popSavedCursor; + break; + case 2004: + bracketedPasteMode = false; + break; + case 3004: + bracketedHyperlinkMode = false; + break; + case 1047: + case 47: + returnToNormalScreen(); + break; + case 25: + cursorShowing = false; + break; + default: unknownEscapeSequence(cast(string) esc); + } + } + break; + case 'X': + // erase characters + auto count = getArgs(1)[0]; + TerminalCell plain; + plain.ch = ' '; + plain.attributes = currentAttributes; + foreach(cnt; 0 .. count) { + ASS[cursorY][cnt + cursorX] = plain; + } + break; + case 'S': + auto count = getArgs(1)[0]; + // scroll up + scrollUp(count); + break; + case 'T': + auto count = getArgs(1)[0]; + // scroll down + scrollDown(count); + break; + case 'P': + auto count = getArgs(1)[0]; + // delete characters + + foreach(cnt; 0 .. count) { + for(int i = cursorX; i < this.screenWidth-1; i++) { + if(ASS[cursorY][i].selected) + clearSelection(); + ASS[cursorY][i] = ASS[cursorY][i + 1]; + ASS[cursorY][i].invalidated = true; + } + + if(ASS[cursorY][this.screenWidth - 1].selected) + clearSelection(); + ASS[cursorY][this.screenWidth-1].ch = ' '; + ASS[cursorY][this.screenWidth-1].invalidated = true; + } + break; + case '@': + // insert blank characters + auto count = getArgs(1)[0]; + foreach(idx; 0 .. count) { + for(int i = this.screenWidth - 1; i > cursorX; i--) { + ASS[cursorY][i] = ASS[cursorY][i - 1]; + ASS[cursorY][i].invalidated = true; + } + ASS[cursorY][cursorX].ch = ' '; + ASS[cursorY][cursorX].invalidated = true; + } + break; + case 'c': + // send device attributes + // FIXME: what am i supposed to do here? + //sendToApplication("\033[>0;138;0c"); + //sendToApplication("\033[?62;"); + sendToApplication(terminalIdCode); + break; + default: + // [42\esc] seems to have gotten here once somehow + // also [24\esc] + unknownEscapeSequence("" ~ cast(string) esc); + } + } else { + unknownEscapeSequence(cast(string) esc); + } + } + } +} + +// These match the numbers in terminal.d, so you can just cast it back and forth +// and the names match simpledisplay.d so you can convert that automatically too +enum TerminalKey : int { + Escape = 0x1b,// + 0xF0000, /// . + F1 = 0x70,// + 0xF0000, /// . + F2 = 0x71,// + 0xF0000, /// . + F3 = 0x72,// + 0xF0000, /// . + F4 = 0x73,// + 0xF0000, /// . + F5 = 0x74,// + 0xF0000, /// . + F6 = 0x75,// + 0xF0000, /// . + F7 = 0x76,// + 0xF0000, /// . + F8 = 0x77,// + 0xF0000, /// . + F9 = 0x78,// + 0xF0000, /// . + F10 = 0x79,// + 0xF0000, /// . + F11 = 0x7A,// + 0xF0000, /// . + F12 = 0x7B,// + 0xF0000, /// . + Left = 0x25,// + 0xF0000, /// . + Right = 0x27,// + 0xF0000, /// . + Up = 0x26,// + 0xF0000, /// . + Down = 0x28,// + 0xF0000, /// . + Insert = 0x2d,// + 0xF0000, /// . + Delete = 0x2e,// + 0xF0000, /// . + Home = 0x24,// + 0xF0000, /// . + End = 0x23,// + 0xF0000, /// . + PageUp = 0x21,// + 0xF0000, /// . + PageDown = 0x22,// + 0xF0000, /// . + ScrollLock = 0x91, +} + +/* These match simpledisplay.d which match terminal.d, so you can just cast them */ + +enum MouseEventType : int { + motion = 0, + buttonPressed = 1, + buttonReleased = 2, +} + +enum MouseButton : int { + // these names assume a right-handed mouse + left = 1, + right = 2, + middle = 4, + wheelUp = 8, + wheelDown = 16, +} + + + +/* +mixin template ImageSupport() { + import arsd.png; + import arsd.bmp; +} +*/ + + +/* helper functions that are generally useful but not necessarily required */ + +version(use_libssh2) { + import arsd.libssh2; + void startChild(alias masterFunc)(string host, short port, string username, string keyFile, string expectedFingerprint = null) { + + int tries = 0; + try_again: + try { + import std.socket; + + if(libssh2_init(0)) + throw new Exception("libssh2_init"); + scope(exit) + libssh2_exit(); + + auto socket = new Socket(AddressFamily.INET, SocketType.STREAM); + socket.connect(new InternetAddress(host, port)); + scope(exit) socket.close(); + + auto session = libssh2_session_init_ex(null, null, null, null); + if(session is null) throw new Exception("init session"); + scope(exit) + libssh2_session_disconnect_ex(session, 0, "normal", "EN"); + + libssh2_session_flag(session, LIBSSH2_FLAG_COMPRESS, 1); + + if(libssh2_session_handshake(session, socket.handle)) + throw new Exception("handshake"); + + auto fingerprint = libssh2_hostkey_hash(session, LIBSSH2_HOSTKEY_HASH_SHA1); + if(expectedFingerprint !is null && fingerprint[0 .. expectedFingerprint.length] != expectedFingerprint) + throw new Exception("fingerprint"); + + import std.string : toStringz; + if(auto err = libssh2_userauth_publickey_fromfile_ex(session, username.ptr, username.length, toStringz(keyFile ~ ".pub"), toStringz(keyFile), null)) + throw new Exception("auth"); + + + auto channel = libssh2_channel_open_ex(session, "session".ptr, "session".length, LIBSSH2_CHANNEL_WINDOW_DEFAULT, LIBSSH2_CHANNEL_PACKET_DEFAULT, null, 0); + + if(channel is null) + throw new Exception("channel open"); + + scope(exit) + libssh2_channel_free(channel); + + // libssh2_channel_setenv_ex(channel, "ELVISBG".dup.ptr, "ELVISBG".length, "dark".ptr, "dark".length); + + if(libssh2_channel_request_pty_ex(channel, "xterm", "xterm".length, null, 0, 80, 24, 0, 0)) + throw new Exception("pty"); + + if(libssh2_channel_process_startup(channel, "shell".ptr, "shell".length, null, 0)) + throw new Exception("process_startup"); + + libssh2_keepalive_config(session, 0, 60); + libssh2_session_set_blocking(session, 0); + + masterFunc(socket, session, channel); + } catch(Exception e) { + if(e.msg == "handshake") { + tries++; + import core.thread; + Thread.sleep(200.msecs); + if(tries < 10) + goto try_again; + } + + throw e; + } + } + +} else +version(Posix) { + extern(C) static int forkpty(int* master, /*int* slave,*/ void* name, void* termp, void* winp); + pragma(lib, "util"); + + /// this is good + void startChild(alias masterFunc)(string program, string[] args) { + import core.sys.posix.termios; + import core.sys.posix.signal; + import core.sys.posix.sys.wait; + __gshared static int childrenAlive = 0; + extern(C) nothrow static @nogc + void childdead(int) { + childrenAlive--; + + wait(null); + + version(with_eventloop) + try { + import arsd.eventloop; + if(childrenAlive <= 0) + exit(); + } catch(Exception e){} + } + + signal(SIGCHLD, &childdead); + + int master; + int pid = forkpty(&master, null, null, null); + if(pid == -1) + throw new Exception("forkpty"); + if(pid == 0) { + import std.process; + environment["TERM"] = "xterm"; // we're closest to an xterm, so definitely want to pretend to be one to the child processes + environment["TERM_EXTENSIONS"] = "arsd"; // announce our extensions + + import std.string; + if(environment["LANG"].indexOf("UTF-8") == -1) + environment["LANG"] = "en_US.UTF-8"; // tell them that utf8 rox (FIXME: what about non-US?) + + import core.sys.posix.unistd; + + import core.stdc.stdlib; + char** argv = cast(char**) malloc((char*).sizeof * (args.length + 1)); + if(argv is null) throw new Exception("malloc"); + foreach(i, arg; args) { + argv[i] = cast(char*) malloc(arg.length + 1); + if(argv[i] is null) throw new Exception("malloc"); + argv[i][0 .. arg.length] = arg[]; + argv[i][arg.length] = 0; + } + + argv[args.length] = null; + + core.sys.posix.unistd.execv(argv[0], argv); + } else { + childrenAlive = 1; + masterFunc(master); + } + } +} else +version(Windows) { + import core.sys.windows.windows; + + version(winpty) { + alias HPCON = HANDLE; + extern(Windows) + HRESULT function(HPCON, COORD) ResizePseudoConsole; + extern(Windows) + HRESULT function(COORD, HANDLE, HANDLE, DWORD, HPCON*) CreatePseudoConsole; + extern(Windows) + void function(HPCON) ClosePseudoConsole; + } + + extern(Windows) + BOOL PeekNamedPipe(HANDLE, LPVOID, DWORD, LPDWORD, LPDWORD, LPDWORD); + extern(Windows) + BOOL GetOverlappedResult(HANDLE,OVERLAPPED*,LPDWORD,BOOL); + extern(Windows) + BOOL ReadFileEx(HANDLE, LPVOID, DWORD, OVERLAPPED*, void*); + extern(Windows) + BOOL PostMessageA(HWND hWnd,UINT Msg,WPARAM wParam,LPARAM lParam); + + extern(Windows) + BOOL PostThreadMessageA(DWORD, UINT, WPARAM, LPARAM); + extern(Windows) + BOOL RegisterWaitForSingleObject( PHANDLE phNewWaitObject, HANDLE hObject, void* Callback, PVOID Context, ULONG dwMilliseconds, ULONG dwFlags); + extern(Windows) + BOOL SetHandleInformation(HANDLE, DWORD, DWORD); + extern(Windows) + HANDLE CreateNamedPipeA( + const(char)* lpName, + DWORD dwOpenMode, + DWORD dwPipeMode, + DWORD nMaxInstances, + DWORD nOutBufferSize, + DWORD nInBufferSize, + DWORD nDefaultTimeOut, + LPSECURITY_ATTRIBUTES lpSecurityAttributes + ); + extern(Windows) + BOOL UnregisterWait(HANDLE); + + struct STARTUPINFOEXA { + STARTUPINFOA StartupInfo; + void* lpAttributeList; + } + + enum PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x00020016; + enum EXTENDED_STARTUPINFO_PRESENT = 0x00080000; + + extern(Windows) + BOOL InitializeProcThreadAttributeList(void*, DWORD, DWORD, PSIZE_T); + extern(Windows) + BOOL UpdateProcThreadAttribute(void*, DWORD, DWORD_PTR, PVOID, SIZE_T, PVOID, PSIZE_T); + + __gshared HANDLE waitHandle; + __gshared bool childDead; + extern(Windows) + void childCallback(void* tidp, bool) { + auto tid = cast(DWORD) tidp; + UnregisterWait(waitHandle); + + PostThreadMessageA(tid, WM_QUIT, 0, 0); + childDead = true; + //stupidThreadAlive = false; + } + + + + extern(Windows) + void SetLastError(DWORD); + + /// this is good. best to call it with plink.exe so it can talk to unix + /// note that plink asks for the password out of band, so it won't actually work like that. + /// thus specify the password on the command line or better yet, use a private key file + /// e.g. + /// startChild!something("plink.exe", "plink.exe user@server -i key.ppk \"/home/user/terminal-emulator/serverside\""); + void startChild(alias masterFunc)(string program, string commandLine) { + import core.sys.windows.windows; + // thanks for a random person on stack overflow for this function + static BOOL MyCreatePipeEx( + PHANDLE lpReadPipe, + PHANDLE lpWritePipe, + LPSECURITY_ATTRIBUTES lpPipeAttributes, + DWORD nSize, + DWORD dwReadMode, + DWORD dwWriteMode + ) + { + HANDLE ReadPipeHandle, WritePipeHandle; + DWORD dwError; + CHAR[MAX_PATH] PipeNameBuffer; + + if (nSize == 0) { + nSize = 4096; + } + + static int PipeSerialNumber = 0; + + import core.stdc.string; + import core.stdc.stdio; + + sprintf(PipeNameBuffer.ptr, + "\\\\.\\Pipe\\TerminalEmulatorPipe.%08x.%08x".ptr, + GetCurrentProcessId(), + PipeSerialNumber++ + ); + + ReadPipeHandle = CreateNamedPipeA( + PipeNameBuffer.ptr, + 1/*PIPE_ACCESS_INBOUND*/ | dwReadMode, + 0/*PIPE_TYPE_BYTE*/ | 0/*PIPE_WAIT*/, + 1, // Number of pipes + nSize, // Out buffer size + nSize, // In buffer size + 120 * 1000, // Timeout in ms + lpPipeAttributes + ); + + if (! ReadPipeHandle) { + return FALSE; + } + + WritePipeHandle = CreateFileA( + PipeNameBuffer.ptr, + GENERIC_WRITE, + 0, // No sharing + lpPipeAttributes, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | dwWriteMode, + null // Template file + ); + + if (INVALID_HANDLE_VALUE == WritePipeHandle) { + dwError = GetLastError(); + CloseHandle( ReadPipeHandle ); + SetLastError(dwError); + return FALSE; + } + + *lpReadPipe = ReadPipeHandle; + *lpWritePipe = WritePipeHandle; + return( TRUE ); + } + + + + + + import std.conv; + + SECURITY_ATTRIBUTES saAttr; + saAttr.nLength = SECURITY_ATTRIBUTES.sizeof; + saAttr.bInheritHandle = true; + saAttr.lpSecurityDescriptor = null; + + HANDLE inreadPipe; + HANDLE inwritePipe; + if(CreatePipe(&inreadPipe, &inwritePipe, &saAttr, 0) == 0) + throw new Exception("CreatePipe"); + if(!SetHandleInformation(inwritePipe, 1/*HANDLE_FLAG_INHERIT*/, 0)) + throw new Exception("SetHandleInformation"); + HANDLE outreadPipe; + HANDLE outwritePipe; + + version(winpty) + auto flags = 0; + else + auto flags = FILE_FLAG_OVERLAPPED; + + if(MyCreatePipeEx(&outreadPipe, &outwritePipe, &saAttr, 0, flags, 0) == 0) + throw new Exception("CreatePipe"); + if(!SetHandleInformation(outreadPipe, 1/*HANDLE_FLAG_INHERIT*/, 0)) + throw new Exception("SetHandleInformation"); + + version(winpty) { + + auto lib = LoadLibrary("kernel32.dll"); + if(lib is null) throw new Exception("holy wtf batman"); + scope(exit) FreeLibrary(lib); + + CreatePseudoConsole = cast(typeof(CreatePseudoConsole)) GetProcAddress(lib, "CreatePseudoConsole"); + ClosePseudoConsole = cast(typeof(ClosePseudoConsole)) GetProcAddress(lib, "ClosePseudoConsole"); + ResizePseudoConsole = cast(typeof(ResizePseudoConsole)) GetProcAddress(lib, "ResizePseudoConsole"); + + if(CreatePseudoConsole is null || ClosePseudoConsole is null || ResizePseudoConsole is null) + throw new Exception("Windows pseudo console not available on this version"); + + initPipeHack(outreadPipe); + + HPCON hpc; + auto result = CreatePseudoConsole( + COORD(80, 24), + inreadPipe, + outwritePipe, + 0, // flags + &hpc + ); + + assert(result == S_OK); + + scope(exit) + ClosePseudoConsole(hpc); + } + + STARTUPINFOEXA siex; + siex.StartupInfo.cb = siex.sizeof; + + version(winpty) { + size_t size; + InitializeProcThreadAttributeList(null, 1, 0, &size); + ubyte[] wtf = new ubyte[](size); + siex.lpAttributeList = wtf.ptr; + InitializeProcThreadAttributeList(siex.lpAttributeList, 1, 0, &size); + UpdateProcThreadAttribute( + siex.lpAttributeList, + 0, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + hpc, + hpc.sizeof, + null, + null + ); + } {//else { + siex.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + siex.StartupInfo.hStdInput = GetStdHandle(STD_INPUT_HANDLE);//inreadPipe; + siex.StartupInfo.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE);//outwritePipe; + siex.StartupInfo.hStdError = GetStdHandle(STD_ERROR_HANDLE);//outwritePipe; + } + + PROCESS_INFORMATION pi; + import std.conv; + + if(commandLine.length > 255) + throw new Exception("command line too long"); + char[256] cmdLine; + cmdLine[0 .. commandLine.length] = commandLine[]; + cmdLine[commandLine.length] = 0; + import std.string; + if(CreateProcessA(program is null ? null : toStringz(program), cmdLine.ptr, null, null, true, EXTENDED_STARTUPINFO_PRESENT /*0x08000000 /* CREATE_NO_WINDOW */, null /* environment */, null, cast(STARTUPINFOA*) &siex, &pi) == 0) + throw new Exception("CreateProcess " ~ to!string(GetLastError())); + + if(RegisterWaitForSingleObject(&waitHandle, pi.hProcess, &childCallback, cast(void*) GetCurrentThreadId(), INFINITE, 4 /* WT_EXECUTEINWAITTHREAD */ | 8 /* WT_EXECUTEONLYONCE */) == 0) + throw new Exception("RegisterWaitForSingleObject"); + + version(winpty) + masterFunc(hpc, inwritePipe, outreadPipe); + else + masterFunc(inwritePipe, outreadPipe); + + //stupidThreadAlive = false; + + //term.stupidThread.join(); + + /* // FIXME: we should close but only if we're legit done + // masterFunc typically runs an event loop but it might not. + CloseHandle(inwritePipe); + CloseHandle(outreadPipe); + + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + */ + } +} + +/// Implementation of TerminalEmulator's abstract functions that forward them to output +mixin template ForwardVirtuals(alias writer) { + static import arsd.color; + + protected override void changeCursorStyle(CursorStyle style) { + // FIXME: this should probably just import utility + final switch(style) { + case TerminalEmulator.CursorStyle.block: + writer("\033[2 q"); + break; + case TerminalEmulator.CursorStyle.underline: + writer("\033[4 q"); + break; + case TerminalEmulator.CursorStyle.bar: + writer("\033[6 q"); + break; + } + } + + protected override void changeWindowTitle(string t) { + import std.process; + if(t.length && environment["TERM"] != "linux") + writer("\033]0;"~t~"\007"); + } + + protected override void changeWindowIcon(arsd.color.IndexedImage t) { + if(t !is null) { + // forward it via our extension. xterm and such seems to ignore this so we should be ok just sending, except to Linux + import std.process; + if(environment["TERM"] != "linux") + writer("\033]5000;" ~ encodeSmallTextImage(t) ~ "\007"); + } + } + + protected override void changeIconTitle(string) {} // FIXME + protected override void changeTextAttributes(TextAttributes) {} // FIXME + protected override void soundBell() { + writer("\007"); + } + protected override void demandAttention() { + import std.process; + if(environment["TERM"] != "linux") + writer("\033]5001;1\007"); // the 1 there means true but is currently ignored + } + protected override void copyToClipboard(string text) { + // this is xterm compatible, though xterm rarely implements it + import std.base64; + // idk why the cast is needed here + writer("\033]52;c;"~Base64.encode(cast(ubyte[])text)~"\007"); + } + protected override void pasteFromClipboard(void delegate(in char[]) dg) { + // this is a slight extension. xterm invented the string - it means request the primary selection - + // but it generally doesn't actually get a reply. so i'm using it to request the primary which will be + // sent as a pasted strong. + // (xterm prolly doesn't do it by default because it is potentially insecure, letting a naughty app steal your clipboard data, but meh, any X application can do that too and it is useful here for nesting.) + writer("\033]52;c;?\007"); + } + protected override void copyToPrimary(string text) { + import std.base64; + writer("\033]52;p;"~Base64.encode(cast(ubyte[])text)~"\007"); + } + protected override void pasteFromPrimary(void delegate(in char[]) dg) { + writer("\033]52;p;?\007"); + } + +} + +/// you can pass this as PtySupport's arguments when you just don't care +final void doNothing() {} + +version(winpty) { + __gshared static HANDLE inputEvent; + __gshared static HANDLE magicEvent; + __gshared static ubyte[] helperBuffer; + __gshared static HANDLE helperThread; + + static void initPipeHack(void* ptr) { + inputEvent = CreateEvent(null, false, false, null); + assert(inputEvent !is null); + magicEvent = CreateEvent(null, false, true, null); + assert(magicEvent !is null); + + helperThread = CreateThread( + null, + 0, + &actuallyRead, + ptr, + 0, + null + ); + + assert(helperThread !is null); + } + + extern(Windows) static + uint actuallyRead(void* ptr) { + ubyte[4096] buffer; + DWORD got; + while(true) { + // wait for the other thread to tell us they + // are done... + WaitForSingleObject(magicEvent, INFINITE); + auto ret = ReadFile(ptr, buffer.ptr, cast(DWORD) buffer.length, &got, null); + helperBuffer = buffer[0 .. got]; + // tells the other thread it is allowed to read + // readyToReadPty + SetEvent(inputEvent); + } + assert(0); + } + + +} + +/// You must implement a function called redraw() and initialize the members in your constructor +mixin template PtySupport(alias resizeHelper) { + // Initialize these! + + final void redraw_() { + if(invalidateAll) { + if(alternateScreenActive) + foreach(ref t; alternateScreen) + t.invalidated = true; + else + foreach(ref t; normalScreen) + t.invalidated = true; + invalidateAll = false; + } + redraw(); + //soundBell(); + } + + version(use_libssh2) { + import arsd.libssh2; + LIBSSH2_CHANNEL* sshChannel; + } else version(Windows) { + import core.sys.windows.windows; + HANDLE stdin; + HANDLE stdout; + } else version(Posix) { + int master; + } + + version(use_libssh2) { } + else version(Posix) { + int previousProcess = 0; + int activeProcess = 0; + int activeProcessWhenResized = 0; + bool resizedRecently; + + /* + so, this isn't perfect, but it is meant to send the resize signal to an existing process + when it isn't in the front when you resize. + + For example, open vim and resize. Then exit vim. We want bash to be updated. + + But also don't want to do too many spurious signals. + + It doesn't handle the case of bash -> vim -> :sh resize, then vim gets signal but + the outer bash won't see it. I guess I need some kind of process stack. + + but it is okish. + */ + override void outputOccurred() { + import core.sys.posix.unistd; + auto pgrp = tcgetpgrp(master); + if(pgrp != -1) { + if(pgrp != activeProcess) { + auto previousProcessAtStartup = previousProcess; + + previousProcess = activeProcess; + activeProcess = pgrp; + + if(resizedRecently) { + if(activeProcess != activeProcessWhenResized) { + resizedRecently = false; + + if(activeProcess == previousProcessAtStartup) { + //import std.stdio; writeln("informing new process ", activeProcess, " of size ", screenWidth, " x ", screenHeight); + + import core.sys.posix.signal; + kill(-activeProcess, 28 /* 28 == SIGWINCH*/); + } + } + } + } + } + + + super.outputOccurred(); + } + //return std.file.readText("/proc/" ~ to!string(pgrp) ~ "/cmdline"); + } + + + override void resizeTerminal(int w, int h) { + version(Posix) { + activeProcessWhenResized = activeProcess; + resizedRecently = true; + } + + resizeHelper(); + + super.resizeTerminal(w, h); + + version(use_libssh2) { + libssh2_channel_request_pty_size_ex(sshChannel, w, h, 0, 0); + } else version(Posix) { + import core.sys.posix.sys.ioctl; + winsize win; + win.ws_col = cast(ushort) w; + win.ws_row = cast(ushort) h; + + ioctl(master, TIOCSWINSZ, &win); + } else version(Windows) { + version(winpty) { + COORD coord; + coord.X = cast(ushort) w; + coord.Y = cast(ushort) h; + ResizePseudoConsole(hpc, coord); + } else { + sendToApplication([cast(ubyte) 254, cast(ubyte) w, cast(ubyte) h]); + } + } else static assert(0); + } + + protected override void sendToApplication(scope const(void)[] data) { + version(use_libssh2) { + while(data.length) { + auto sent = libssh2_channel_write_ex(sshChannel, 0, data.ptr, data.length); + if(sent < 0) + throw new Exception("libssh2_channel_write_ex"); + data = data[sent .. $]; + } + } else version(Windows) { + import std.conv; + uint written; + if(WriteFile(stdin, data.ptr, cast(uint)data.length, &written, null) == 0) + throw new Exception("WriteFile " ~ to!string(GetLastError())); + } else version(Posix) { + import core.sys.posix.unistd; + while(data.length) { + enum MAX_SEND = 1024 * 20; + auto sent = write(master, data.ptr, data.length > MAX_SEND ? MAX_SEND : cast(int) data.length); + //import std.stdio; writeln("ROFL ", sent, " ", data.length); + + import core.stdc.errno; + /* + if(sent == -1 && errno == 11) { + import core.thread; + Thread.sleep(100.msecs); + //import std.stdio; writeln("lol"); + continue; // just try again + } + */ + + import std.conv; + if(sent < 0) + throw new Exception("write " ~ to!string(errno)); + + data = data[sent .. $]; + } + } else static assert(0); + } + + version(use_libssh2) { + int readyToRead(int fd) { + int count = 0; // if too much stuff comes at once, we still want to be responsive + while(true) { + ubyte[4096] buffer; + auto got = libssh2_channel_read_ex(sshChannel, 0, buffer.ptr, buffer.length); + if(got == LIBSSH2_ERROR_EAGAIN) + break; // got it all for now + if(got < 0) + throw new Exception("libssh2_channel_read_ex"); + if(got == 0) + break; // NOT an error! + + super.sendRawInput(buffer[0 .. got]); + count++; + + if(count == 5) { + count = 0; + redraw_(); + justRead(); + } + } + + if(libssh2_channel_eof(sshChannel)) { + libssh2_channel_close(sshChannel); + libssh2_channel_wait_closed(sshChannel); + + return 1; + } + + if(count != 0) { + redraw_(); + justRead(); + } + return 0; + } + } else version(winpty) { + void readyToReadPty() { + super.sendRawInput(helperBuffer); + SetEvent(magicEvent); // tell the other thread we have finished + redraw_(); + justRead(); + } + } else version(Windows) { + OVERLAPPED* overlapped; + bool overlappedBufferLocked; + ubyte[4096] overlappedBuffer; + extern(Windows) + static final void readyToReadWindows(DWORD errorCode, DWORD numberOfBytes, OVERLAPPED* overlapped) { + assert(overlapped !is null); + typeof(this) w = cast(typeof(this)) overlapped.hEvent; + + if(numberOfBytes) { + w.sendRawInput(w.overlappedBuffer[0 .. numberOfBytes]); + w.redraw_(); + } + import std.conv; + + if(ReadFileEx(w.stdout, w.overlappedBuffer.ptr, w.overlappedBuffer.length, overlapped, &readyToReadWindows) == 0) { + if(GetLastError() == 997) + { } // there's pending i/o, let's just ignore for now and it should tell us later that it completed + else + throw new Exception("ReadFileEx " ~ to!string(GetLastError())); + } else { + } + + w.justRead(); + } + } else version(Posix) { + void readyToRead(int fd) { + import core.sys.posix.unistd; + ubyte[4096] buffer; + + // the count is to limit how long we spend in this loop + // when it runs out, it goes back to the main event loop + // for a while (btw use level triggered events so the remaining + // data continues to get processed!) giving a chance to redraw + // and process user input periodically during insanely long and + // rapid output. + int cnt = 50; // the actual count is arbitrary, it just seems nice in my tests + + version(arsd_te_conservative_draws) + cnt = 400; + + // FIXME: if connected by ssh, up the count so we don't redraw as frequently. + // it'd save bandwidth + + while(--cnt) { + auto len = read(fd, buffer.ptr, 4096); + if(len < 0) { + import core.stdc.errno; + if(errno == EAGAIN || errno == EWOULDBLOCK) { + break; // we got it all + } else { + //import std.conv; + //throw new Exception("read failed " ~ to!string(errno)); + return; + } + } + + if(len == 0) { + close(fd); + requestExit(); + break; + } + + auto data = buffer[0 .. len]; + + if(debugMode) { + import std.array; import std.stdio; writeln("GOT ", data, "\nOR ", + replace(cast(string) data, "\033", "\\") + .replace("\010", "^H") + .replace("\r", "^M") + .replace("\n", "^J") + ); + } + super.sendRawInput(data); + } + + outputOccurred(); + + redraw_(); + + // HACK: I don't even know why this works, but with this + // sleep in place, it gives X events from that socket a + // chance to be processed. It can add a few seconds to a huge + // output (like `find /usr`), but meh, that's worth it to me + // to have a chance to ctrl+c. + import core.thread; + Thread.sleep(dur!"msecs"(5)); + + justRead(); + } + } +} + +mixin template SdpyImageSupport() { + class NonCharacterData_Image : NonCharacterData { + Image data; + int imageOffsetX; + int imageOffsetY; + + this(Image data, int x, int y) { + this.data = data; + this.imageOffsetX = x; + this.imageOffsetY = y; + } + } + + protected override BrokenUpImage handleBinaryExtensionData(const(ubyte)[] binaryData) { + TrueColorImage mi; + + if(binaryData.length > 8 && binaryData[1] == 'P' && binaryData[2] == 'N' && binaryData[3] == 'G') { + import arsd.png; + mi = imageFromPng(readPng(binaryData)).getAsTrueColorImage(); + } else if(binaryData.length > 8 && binaryData[0] == 'B' && binaryData[1] == 'M') { + import arsd.bmp; + mi = readBmp(binaryData).getAsTrueColorImage(); + } else if(binaryData.length > 2 && binaryData[0] == 0xff && binaryData[1] == 0xd8) { + import arsd.jpeg; + mi = readJpegFromMemory(binaryData).getAsTrueColorImage(); + } else if(binaryData.length > 2 && binaryData[0] == '<') { + import arsd.svg; + NSVG* image = nsvgParse(cast(const(char)[]) binaryData); + if(image is null) + return BrokenUpImage(); + + int w = cast(int) image.width + 1; + int h = cast(int) image.height + 1; + NSVGrasterizer rast = nsvgCreateRasterizer(); + mi = new TrueColorImage(w, h); + rasterize(rast, image, 0, 0, 1, mi.imageData.bytes.ptr, w, h, w*4); + image.kill(); + } else { + return BrokenUpImage(); + } + + BrokenUpImage bi; + bi.width = mi.width / fontWidth + ((mi.width%fontWidth) ? 1 : 0); + bi.height = mi.height / fontHeight + ((mi.height%fontHeight) ? 1 : 0); + + bi.representation.length = bi.width * bi.height; + + Image data = Image.fromMemoryImage(mi); + + int ix, iy; + foreach(ref cell; bi.representation) { + /* + Image data = new Image(fontWidth, fontHeight); + foreach(y; 0 .. fontHeight) { + foreach(x; 0 .. fontWidth) { + if(x + ix >= mi.width || y + iy >= mi.height) { + data.putPixel(x, y, defaultTextAttributes.background); + continue; + } + data.putPixel(x, y, mi.imageData.colors[(iy + y) * mi.width + (ix + x)]); + } + } + */ + + cell.nonCharacterData = new NonCharacterData_Image(data, ix, iy); + + ix += fontWidth; + + if(ix >= mi.width) { + ix = 0; + iy += fontHeight; + } + + } + + return bi; + } + +} + +// this assumes you have imported arsd.simpledisplay and/or arsd.minigui in the mixin scope +mixin template SdpyDraw() { + + // black bg, make the colors more visible + static Color contrastify(Color c) { + if(c == Color(0xcd, 0, 0)) + return Color.fromHsl(0, 1.0, 0.75); + else if(c == Color(0, 0, 0xcd)) + return Color.fromHsl(240, 1.0, 0.75); + else if(c == Color(229, 229, 229)) + return Color(0x99, 0x99, 0x99); + else if(c == Color.black) + return Color(128, 128, 128); + else return c; + } + + // white bg, make them more visible + static Color antiContrastify(Color c) { + if(c == Color(0xcd, 0xcd, 0)) + return Color.fromHsl(60, 1.0, 0.25); + else if(c == Color(0, 0xcd, 0xcd)) + return Color.fromHsl(180, 1.0, 0.25); + else if(c == Color(229, 229, 229)) + return Color(0x99, 0x99, 0x99); + else if(c == Color.white) + return Color(128, 128, 128); + else return c; + } + + struct SRectangle { + int left; + int top; + int right; + int bottom; + } + + mixin SdpyImageSupport; + + OperatingSystemFont font; + int fontWidth; + int fontHeight; + + enum paddingLeft = 2; + enum paddingTop = 1; + + void loadDefaultFont(int size = 14) { + static if(UsingSimpledisplayX11) { + font = new OperatingSystemFont("fixed", size, FontWeight.medium); + if(font.isNull) { + // didn't work, it is using a + // fallback, prolly fixed-13 is best + font = new OperatingSystemFont("fixed", 13, FontWeight.medium); + } + } else version(Windows) { + this.font = new OperatingSystemFont("Courier New", size, FontWeight.medium); + } + + fontWidth = font.averageWidth; + fontHeight = font.height; + } + + bool lastDrawAlternativeScreen; + final SRectangle redrawPainter(T)(T painter, bool forceRedraw) { + SRectangle invalidated; + + // FIXME: anything we can do to make this faster is good + // on both, the XImagePainter could use optimizations + // on both, drawing blocks would probably be good too - not just one cell at a time, find whole blocks of stuff + // on both it might also be good to keep scroll commands high level somehow. idk. + + // FIXME on Windows it would definitely help a lot to do just one ExtTextOutW per line, if possible. the current code is brutally slow + + // Or also see https://docs.microsoft.com/en-us/windows/desktop/api/wingdi/nf-wingdi-polytextoutw + + static if(is(T == WidgetPainter) || is(T == ScreenPainter)) { + if(font) + painter.setFont(font); + } + + + int posx = paddingLeft; + int posy = paddingTop; + + + char[512] bufferText; + bool hasBufferedInfo; + int bufferTextLength; + Color bufferForeground; + Color bufferBackground; + int bufferX = -1; + int bufferY = -1; + bool bufferReverse; + void flushBuffer() { + if(!hasBufferedInfo) { + return; + } + + assert(posx - bufferX - 1 > 0); + + painter.fillColor = bufferReverse ? bufferForeground : bufferBackground; + painter.outlineColor = bufferReverse ? bufferForeground : bufferBackground; + + painter.drawRectangle(Point(bufferX, bufferY), posx - bufferX, fontHeight); + painter.fillColor = Color.transparent; + // Hack for contrast! + if(bufferBackground == Color.black && !bufferReverse) { + // brighter than normal in some cases so i can read it easily + painter.outlineColor = contrastify(bufferForeground); + } else if(bufferBackground == Color.white && !bufferReverse) { + // darker than normal so i can read it + painter.outlineColor = antiContrastify(bufferForeground); + } else if(bufferForeground == bufferBackground) { + // color on itself, I want it visible too + auto hsl = toHsl(bufferForeground, true); + if(hsl[2] < 0.5) + hsl[2] += 0.5; + else + hsl[2] -= 0.5; + painter.outlineColor = fromHsl(hsl[0], hsl[1], hsl[2]); + + } else { + // normal + painter.outlineColor = bufferReverse ? bufferBackground : bufferForeground; + } + + // FIXME: make sure this clips correctly + painter.drawText(Point(bufferX, bufferY), cast(immutable) bufferText[0 .. bufferTextLength]); + + hasBufferedInfo = false; + + bufferReverse = false; + bufferTextLength = 0; + bufferX = -1; + bufferY = -1; + } + + + + int x; + foreach(idx, ref cell; alternateScreenActive ? alternateScreen : normalScreen) { + if(!forceRedraw && !cell.invalidated && lastDrawAlternativeScreen == alternateScreenActive) { + flushBuffer(); + goto skipDrawing; + } + cell.invalidated = false; + version(none) if(bufferX == -1) { // why was this ever here? + bufferX = posx; + bufferY = posy; + } + + if(!cell.hasNonCharacterData) { + + invalidated.left = posx < invalidated.left ? posx : invalidated.left; + invalidated.top = posy < invalidated.top ? posy : invalidated.top; + int xmax = posx + fontWidth; + int ymax = posy + fontHeight; + invalidated.right = xmax > invalidated.right ? xmax : invalidated.right; + invalidated.bottom = ymax > invalidated.bottom ? ymax : invalidated.bottom; + + // FIXME: this could be more efficient, simpledisplay could get better graphics context handling + { + + bool reverse = (cell.attributes.inverse != reverseVideo); + if(cell.selected) + reverse = !reverse; + + version(with_24_bit_color) { + auto fgc = cell.attributes.foreground; + auto bgc = cell.attributes.background; + + if(!(cell.attributes.foregroundIndex & 0xff00)) { + // this refers to a specific palette entry, which may change, so we should use that + fgc = palette[cell.attributes.foregroundIndex]; + } + if(!(cell.attributes.backgroundIndex & 0xff00)) { + // this refers to a specific palette entry, which may change, so we should use that + bgc = palette[cell.attributes.backgroundIndex]; + } + + } else { + auto fgc = cell.attributes.foregroundIndex == 256 ? defaultForeground : palette[cell.attributes.foregroundIndex & 0xff]; + auto bgc = cell.attributes.backgroundIndex == 256 ? defaultBackground : palette[cell.attributes.backgroundIndex & 0xff]; + } + + if(fgc != bufferForeground || bgc != bufferBackground || reverse != bufferReverse) + flushBuffer(); + bufferReverse = reverse; + bufferBackground = bgc; + bufferForeground = fgc; + } + } + + if(!cell.hasNonCharacterData) { + char[4] str; + import std.utf; + // now that it is buffered, we do want to draw it this way... + //if(cell.ch != ' ') { // no point wasting time drawing spaces, which are nothing; the bg rectangle already did the important thing + try { + auto stride = encode(str, cell.ch); + if(bufferTextLength + stride > bufferText.length) + flushBuffer(); + bufferText[bufferTextLength .. bufferTextLength + stride] = str[0 .. stride]; + bufferTextLength += stride; + + if(bufferX == -1) { + bufferX = posx; + bufferY = posy; + } + hasBufferedInfo = true; + } catch(Exception e) { + import std.stdio; + writeln(cast(uint) cell.ch, " :: ", e.msg); + } + //} + } else if(cell.nonCharacterData !is null) { + //import std.stdio; writeln(cast(void*) cell.nonCharacterData); + if(auto ncdi = cast(NonCharacterData_Image) cell.nonCharacterData) { + flushBuffer(); + painter.outlineColor = defaultBackground; + painter.fillColor = defaultBackground; + painter.drawRectangle(Point(posx, posy), fontWidth, fontHeight); + painter.drawImage(Point(posx, posy), ncdi.data, Point(ncdi.imageOffsetX, ncdi.imageOffsetY), fontWidth, fontHeight); + } + } + + if(!cell.hasNonCharacterData) + if(cell.attributes.underlined) { + // the posx adjustment is because the buffer assumes it is going + // to be flushed after advancing, but here, we're doing it mid-character + // FIXME: we should just underline the whole thing consecutively, with the buffer + posx += fontWidth; + flushBuffer(); + posx -= fontWidth; + painter.drawLine(Point(posx, posy + fontHeight - 1), Point(posx + fontWidth, posy + fontHeight - 1)); + } + skipDrawing: + + posx += fontWidth; + x++; + if(x == screenWidth) { + flushBuffer(); + x = 0; + posy += fontHeight; + posx = paddingLeft; + } + } + + if(cursorShowing) { + painter.fillColor = cursorColor; + painter.outlineColor = cursorColor; + painter.rasterOp = RasterOp.xor; + + posx = cursorPosition.x * fontWidth + paddingLeft; + posy = cursorPosition.y * fontHeight + paddingTop; + + int cursorWidth = fontWidth; + int cursorHeight = fontHeight; + + final switch(cursorStyle) { + case CursorStyle.block: + painter.drawRectangle(Point(posx, posy), cursorWidth, cursorHeight); + break; + case CursorStyle.underline: + painter.drawRectangle(Point(posx, posy + cursorHeight - 2), cursorWidth, 2); + break; + case CursorStyle.bar: + painter.drawRectangle(Point(posx, posy), 2, cursorHeight); + break; + } + painter.rasterOp = RasterOp.normal; + + // since the cursor draws over the cell, we need to make sure it is redrawn each time too + auto buffer = alternateScreenActive ? (&alternateScreen) : (&normalScreen); + if(cursorX >= 0 && cursorY >= 0 && cursorY < screenHeight && cursorX < screenWidth) { + (*buffer)[cursorY * screenWidth + cursorX].invalidated = true; + } + + invalidated.left = posx < invalidated.left ? posx : invalidated.left; + invalidated.top = posy < invalidated.top ? posy : invalidated.top; + int xmax = posx + fontWidth; + int ymax = xmax + fontHeight; + invalidated.right = xmax > invalidated.right ? xmax : invalidated.right; + invalidated.bottom = ymax > invalidated.bottom ? ymax : invalidated.bottom; + } + + lastDrawAlternativeScreen = alternateScreenActive; + + return invalidated; + } +} + +string encodeSmallTextImage(IndexedImage ii) { + char encodeNumeric(int c) { + if(c < 10) + return cast(char)(c + '0'); + if(c < 10 + 26) + return cast(char)(c - 10 + 'a'); + assert(0); + } + + string s; + s ~= encodeNumeric(ii.width); + s ~= encodeNumeric(ii.height); + + foreach(entry; ii.palette) + s ~= entry.toRgbaHexString(); + s ~= "Z"; + + ubyte rleByte; + int rleCount; + + void rleCommit() { + if(rleByte >= 26) + assert(0); // too many colors for us to handle + if(rleCount == 0) + goto finish; + if(rleCount == 1) { + s ~= rleByte + 'a'; + goto finish; + } + + import std.conv; + s ~= to!string(rleCount); + s ~= rleByte + 'a'; + + finish: + rleByte = 0; + rleCount = 0; + } + + foreach(b; ii.data) { + if(b == rleByte) + rleCount++; + else { + rleCommit(); + rleByte = b; + rleCount = 1; + } + } + + rleCommit(); + + return s; +} + +IndexedImage readSmallTextImage(scope const(char)[] arg) { + auto origArg = arg; + int width; + int height; + + int readNumeric(char c) { + if(c >= '0' && c <= '9') + return c - '0'; + if(c >= 'a' && c <= 'z') + return c - 'a' + 10; + return 0; + } + + if(arg.length > 2) { + width = readNumeric(arg[0]); + height = readNumeric(arg[1]); + arg = arg[2 .. $]; + } + + import std.conv; + assert(width == 16, to!string(width)); + assert(height == 16, to!string(width)); + + Color[] palette; + ubyte[256] data; + int didx = 0; + bool readingPalette = true; + outer: while(arg.length) { + if(readingPalette) { + if(arg[0] == 'Z') { + readingPalette = false; + arg = arg[1 .. $]; + continue; + } + if(arg.length < 8) + break; + foreach(a; arg[0..8]) { + // if not strict hex, forget it + if(!((a >= '0' && a <= '9') || (a >= 'a' && a <= 'z') || (a >= 'A' && a <= 'Z'))) + break outer; + } + palette ~= Color.fromString(arg[0 .. 8]); + arg = arg[8 .. $]; + } else { + char[3] rleChars; + int rlePos; + while(arg.length && arg[0] >= '0' && arg[0] <= '9') { + rleChars[rlePos] = arg[0]; + arg = arg[1 .. $]; + rlePos++; + if(rlePos >= rleChars.length) + break; + } + if(arg.length == 0) + break; + + int rle; + if(rlePos == 0) + rle = 1; + else { + // 100 + // rleChars[0] == '1' + foreach(c; rleChars[0 .. rlePos]) { + rle *= 10; + rle += c - '0'; + } + } + + foreach(i; 0 .. rle) { + if(arg[0] >= 'a' && arg[0] <= 'z') + data[didx] = cast(ubyte)(arg[0] - 'a'); + + didx++; + if(didx == data.length) + break outer; + } + + arg = arg[1 .. $]; + } + } + + // width, height, palette, data is set up now + + if(palette.length) { + auto ii = new IndexedImage(width, height); + ii.palette = palette; + ii.data = data.dup; + + return ii; + }// else assert(0, origArg); + return null; +} + + +// workaround dmd bug fixed in next release +//static immutable Color[256] xtermPalette = [ +immutable(Color)[] xtermPalette() { + + // This is an approximation too for a few entries, but a very close one. + Color xtermPaletteIndexToColor(int paletteIdx) { + Color color; + color.a = 255; + + if(paletteIdx < 16) { + if(paletteIdx == 7) + return Color(229, 229, 229); // real is 0xc0 but i think this is easier to see + else if(paletteIdx == 8) + return Color(0x80, 0x80, 0x80); + + // real xterm uses 0x88 here, but I prefer 0xcd because it is easier for me to see + color.r = (paletteIdx & 0b001) ? ((paletteIdx & 0b1000) ? 0xff : 0xcd) : 0x00; + color.g = (paletteIdx & 0b010) ? ((paletteIdx & 0b1000) ? 0xff : 0xcd) : 0x00; + color.b = (paletteIdx & 0b100) ? ((paletteIdx & 0b1000) ? 0xff : 0xcd) : 0x00; + + } else if(paletteIdx < 232) { + // color ramp, 6x6x6 cube + color.r = cast(ubyte) ((paletteIdx - 16) / 36 * 40 + 55); + color.g = cast(ubyte) (((paletteIdx - 16) % 36) / 6 * 40 + 55); + color.b = cast(ubyte) ((paletteIdx - 16) % 6 * 40 + 55); + + if(color.r == 55) color.r = 0; + if(color.g == 55) color.g = 0; + if(color.b == 55) color.b = 0; + } else { + // greyscale ramp, from 0x8 to 0xee + color.r = cast(ubyte) (8 + (paletteIdx - 232) * 10); + color.g = color.r; + color.b = color.g; + } + + return color; + } + + static immutable(Color)[] ret; + if(ret.length == 256) + return ret; + + ret.reserve(256); + foreach(i; 0 .. 256) + ret ~= xtermPaletteIndexToColor(i); + + return ret; +} + +static shared immutable dchar[dchar] lineDrawingCharacterSet; +shared static this() { + lineDrawingCharacterSet = [ + 'a' : ':', + 'j' : '+', + 'k' : '+', + 'l' : '+', + 'm' : '+', + 'n' : '+', + 'q' : '-', + 't' : '+', + 'u' : '+', + 'v' : '+', + 'w' : '+', + 'x' : '|', + ]; + + // this is what they SHOULD be but the font i use doesn't support all these + // the ascii fallback above looks pretty good anyway though. + version(none) + lineDrawingCharacterSet = [ + 'a' : '\u2592', + 'j' : '\u2518', + 'k' : '\u2510', + 'l' : '\u250c', + 'm' : '\u2514', + 'n' : '\u253c', + 'q' : '\u2500', + 't' : '\u251c', + 'u' : '\u2524', + 'v' : '\u2534', + 'w' : '\u252c', + 'x' : '\u2502', + ]; +} + +/+ +Copyright: Adam D. Ruppe, 2013 - 2020 +License: [http://www.boost.org/LICENSE_1_0.txt|Boost Software License 1.0] +Authors: Adam D. Ruppe ++/