From fd1fbd6a5e6aa634b62f0885a4e99f0ceff1408c Mon Sep 17 00:00:00 2001 From: Dannii Willis Date: Fri, 23 Dec 2016 13:07:57 +1000 Subject: [PATCH] Add a Dialog implementation and support for Glk's file functions --- .eslintignore | 2 + README.md | 6 + package.json | 2 +- src/electrofs.js | 640 +++++++++++++++++++++++++++++++++++++++++++++ src/glkapi.js | 152 +++++++---- src/glkote-dumb.js | 100 +++++-- src/glkote-term.js | 15 +- tests/zvm.js | 1 + 8 files changed, 846 insertions(+), 72 deletions(-) create mode 100644 .eslintignore create mode 100644 README.md create mode 100644 src/electrofs.js diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..95c7e3a --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +src/electrofs.js +src/glkapi.js \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..da024ea --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +GlkOte-term +======== + +This is terminal implementation of the [GlkOte](https://github.com/erkyrath/glkote) library. At the moment only a stdio (DumbGlkOte) implementation is available: it allows only one window to be created, and is broadly compatible with the C [Cheapglk](https://github.com/erkyrath/cheapglk) library. In the future a multi-window implementation will be developed. GlkOte-term includes glkapi.js and a version of the Dialog library. + +For sample code, see [tests/zvm.js](https://github.com/curiousdannii/glkote-term/blob/master/tests/zvm.js). \ No newline at end of file diff --git a/package.json b/package.json index cd62cb4..33b97b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "glkote-term", - "version": "0.1.0", + "version": "0.2.0", "description": "Javascript terminal implementation of GlkOte", "author": "Dannii Willis ", "license": "MIT", diff --git a/src/electrofs.js b/src/electrofs.js new file mode 100644 index 0000000..922d8f7 --- /dev/null +++ b/src/electrofs.js @@ -0,0 +1,640 @@ +/* ElectroFS -- a Javascript load/save library for IF interfaces + * Designed by Andrew Plotkin + * + * + * This Javascript library is copyright 2016 by Andrew Plotkin. + * It is distributed under the MIT license; see the "LICENSE" file. + * + * This is a (mostly-) drop-in replacement for dialog.js for node and the + * Electron.io environment. It uses the Node.js "fs" and "path" packages to + * read and write files, and the Electron.io "dialog" package to present + * file-selection dialogs. + * + * The interface is similar to dialog.js, but not exactly the same. (Sorry! + * The Atom/Electron API didn't exist when I write dialog.js, or I would + * have come up with a cleaner abstraction.) + * + * This presents itself as a Dialog module. To distinguish it from dialog.js, + * look at Dialog.streaming, which will be true for electrofs.js and false for + * dialog.js. + */ + +'use strict'; + +var Dialog = function() { + +const fs = require('fs'); +const os = require('os'); +const path_mod = require('path'); +const buffer_mod = require('buffer'); + +/* Constants -- same as in glkapi.js. */ +const filemode_Write = 0x01; +const filemode_Read = 0x02; +const filemode_ReadWrite = 0x03; +const filemode_WriteAppend = 0x05; +const seekmode_Start = 0; +const seekmode_Current = 1; +const seekmode_End = 2; +const fileusage_Data = 0x00; +const fileusage_SavedGame = 0x01; +const fileusage_Transcript = 0x02; +const fileusage_InputRecord = 0x03; + +/* The size of our stream buffering. */ +const BUFFER_SIZE = 256; + +/* FStream -- constructor for a file stream. This is what file_fopen() + * returns. It is analogous to a FILE* in C code. + */ +function FStream(fmode, filename) +{ + this.fmode = fmode; + this.filename = filename; + this.fd = null; /* will be filled in by file_fopen */ + + this.mark = 0; /* read-write position in the file (or buffer start pos) */ + + /* We buffer input or output (but never both at the same time). */ + this.buffer = new buffer_mod.Buffer(BUFFER_SIZE); + /* bufuse is filemode_Read or filemode_Write, if the buffer is being used + for reading or writing. For writing, the buffer starts at mark and + covers buflen bytes. For reading, the buffer starts at mark amd runs + buflen bytes, but bufmark bytes have been consumed from it. */ + this.bufuse = 0; + this.buflen = 0; /* how much of the buffer is used */ + this.bufmark = 0; /* how much of the buffer has been read out (readmode only) */ +} +FStream.prototype = { + + /* Export constructor for Buffer objects. See + https://nodejs.org/dist/latest-v5.x/docs/api/buffer.html */ + BufferClass : buffer_mod.Buffer, + + /* fstream.fclose() -- close a file + */ + fclose : function() { + if (this.fd === null) { + if (typeof GlkOte !== 'undefined') { + GlkOte.log('file_fclose: file already closed: ' + this.filename); + } + return; + } + /* flush any unwritten data */ + this.fflush(); + fs.closeSync(this.fd); + this.fd = null; + this.buffer = null; + }, + + /* fstream.fread(buf, [len]) -- read bytes from a file + * + * Up to buf.length bytes are read into the given buffer. If the len + * argument is given, up to len bytes are read; the buffer must be at least + * len bytes long. Returns the number of bytes read, or 0 if end-of-file. + */ + fread : function(buf, len) { + if (len === undefined) + len = buf.length; + + /* got will be our mark in the buf argument. When got reaches + len, we're done. (Unless we hit EOF first.) */ + var got = 0; + + while (true) { + if (this.bufuse == filemode_Read) { + if (this.bufmark < this.buflen) { + var want = len - got; + if (want > this.buflen - this.bufmark) + want = this.buflen - this.bufmark; + if (want > 0) { + this.buffer.copy(buf, got, this.bufmark, this.bufmark+want); + this.bufmark += want; + got += want; + } + } + if (got >= len) + return got; + + /* We need more, but we've consumed the entire buffer. Fall + through to the next step where we will fflush and keep + reading. */ + } + + if (this.bufuse) + this.fflush(); + + /* ### if len-got >= BUFFER_SIZE, we could read directly and ignore + our buffer. */ + + this.bufuse = filemode_Read; + this.bufmark = 0; + this.buflen = fs.readSync(this.fd, this.buffer, 0, BUFFER_SIZE, this.mark); + if (this.buflen == 0) { + /* End of file. Mark the buffer unused, since it's empty. */ + this.bufuse = 0; + return got; + } + /* mark stays at the buffer start position */ + } + }, + + /* fstream.fwrite(buf, [len]) -- write data to a file + * + * buf.length bytes are written to the stream. If the len argument is + * given, that many bytes are written; the buffer must be at least len + * bytes long. Return the number of bytes written. + */ + fwrite : function(buf, len) { + if (len === undefined) + len = buf.length; + + var from = 0; + + while (true) { + if (this.bufuse == filemode_Write) { + var want = len - from; + if (want > BUFFER_SIZE - this.buflen) + want = BUFFER_SIZE - this.buflen; + if (want > 0) { + buf.copy(this.buffer, this.buflen, from, from+want); + this.buflen += want; + from += want; + } + } + if (from >= len) + return from; + + /* We need to write more, but the buffer is full. Fall through + to the next step where we will fflush and keep writing. */ + + if (this.bufuse) + this.fflush(); + + /* ### if len-from >= BUFFER_SIZE, we could write directly and + ignore our buffer. */ + + this.bufuse = filemode_Write; + this.buflen = 0; + } + }, + + ftell : function() { + if (this.bufuse == filemode_Read) { + return this.mark + this.bufmark; + } + else if (this.bufuse == filemode_Write) { + return this.mark + this.buflen; + } + else { + return this.mark; + } + }, + + fseek : function(pos, seekmode) { + /* ### we could seek within the current buffer, which would be + efficient for small moves. */ + this.fflush(); + + var val = 0; + if (seekmode == seekmode_Current) { + val = this.mark + pos; + } + else if (seekmode == seekmode_End) { + try { + var stats = fs.fstatSync(this.fd); + val = stats.size + pos; + } + catch (ex) { + val = this.mark + pos; + } + } + else { + val = pos; + } + if (val < 0) + val = 0; + this.mark = val; + }, + + fflush : function() { + if (this.bufuse == filemode_Read) { + /* Do nothing, just advance the mark. */ + this.mark += this.bufmark; + } + else if (this.bufuse == filemode_Write) { + if (this.buflen) { + var count = fs.writeSync(this.fd, this.buffer, 0, this.buflen, this.mark); + this.mark += count; + } + } + this.bufuse = 0; + this.buflen = 0; + this.bufmark = 0; + } + +}; + +/* A generic Node fs based dialog class */ +class Dialog { + constructor() { + this.streaming = true; + this.userpath = this.get_user_path(); + this.extfilepath = path_mod.join(this.userpath, 'quixe-files'); + + /* We try to create a directory for external files at launch time. + This will usually fail because there's already a directory there. + */ + try { + fs.mkdirSync(this.extfilepath); + } + catch (ex) {} + } + + /* Load a snapshot (a JSONable object) from a signature-dependent location. + */ + autosave_read(signature) + { + var gamedirpath = path_mod.join(this.userpath, 'games', signature); + var pathj = path_mod.join(gamedirpath, 'autosave.json'); + var pathr = path_mod.join(gamedirpath, 'autosave.ram'); + + try { + var str = fs.readFileSync(pathj, { encoding:'utf8' }); + var snapshot = JSON.parse(str); + + try { + var buf = fs.readFileSync(pathr, { encoding:null }); + var ram = Array.from(buf.values()); + snapshot.ram = ram; + } + catch (ex) {}; + + return snapshot; + } + catch (ex) { + return null; + }; + } + + /* Store a snapshot (a JSONable object) in a signature-dependent location. + If snapshot is null, delete the snapshot instead. + */ + autosave_write(signature, snapshot) + { + var gamedirpath = path_mod.join(this.userpath, 'games', signature); + + /* Make sure the gamedirpath exists. */ + var stat = null; + try { + stat = fs.statSync(gamedirpath); + } + catch (ex) {}; + if (!stat || !stat.isDirectory()) { + try { + fs.mkdirSync(path_mod.join(this.userpath, 'games')); + } + catch (ex) {}; + try { + fs.mkdirSync(gamedirpath); + } + catch (ex) {}; + stat = null; + try { + stat = fs.statSync(gamedirpath); + } + catch (ex) {}; + if (!stat || !stat.isDirectory()) { + /* Can't create the directory; give up. */ + this.log('Unable to create gamedirpath: ' + gamedirpath); + return; + } + } + + /* We'll save the snapshot in two files: a .ram file (snapshot.ram + as raw bytes) and a .json file (the rest of snapshot, stringified). + */ + + var pathj = path_mod.join(gamedirpath, 'autosave.json'); + var pathr = path_mod.join(gamedirpath, 'autosave.ram'); + + if (!snapshot) { + try { + fs.unlinkSync(pathj); + } + catch (ex) {}; + try { + fs.unlinkSync(pathr); + } + catch (ex) {}; + return; + } + + /* Pull snapshot.ram out, if it exists. (It's okay to munge the + snapshot object,the caller doesn't want it back.) */ + var ram = snapshot.ram; + snapshot.ram = undefined; + + var str = JSON.stringify(snapshot); + fs.writeFileSync(pathj, str, { encoding:'utf8' }); + + if (ram) { + var buf = new buffer_mod.Buffer(ram); + fs.writeFileSync(pathr, buf); + } + } + + /* Dialog.file_clean_fixed_name(filename, usage) -- clean up a filename + * + * Take an arbitrary string and convert it into a filename that can + * validly be stored in the user's directory. This is called for filenames + * that come from the game file, but not for filenames selected directly + * by the user (i.e. from a file selection dialog). + * + * The new spec recommendations: delete all characters in the string + * "/\<>:|?*" (including quotes). Truncate at the first period. Change to + * "null" if there's nothing left. Then append an appropriate suffix: + * ".glkdata", ".glksave", ".txt". + */ + file_clean_fixed_name(filename, usage) { + var res = filename.replace(/["/\\<>:|?*]/g, ''); + var pos = res.indexOf('.'); + if (pos >= 0) + res = res.slice(0, pos); + if (res.length == 0) + res = "null"; + + switch (usage) { + case fileusage_Data: + return res + '.glkdata'; + case fileusage_SavedGame: + return res + '.glksave'; + case fileusage_Transcript: + case fileusage_InputRecord: + return res + '.txt'; + default: + return res; + } + } + + /* Dialog.file_construct_ref(filename, usage, gameid) -- create a fileref + * + * Create a fileref. This does not create a file; it's just a thing you can use + * to read an existing file or create a new one. Any unspecified arguments are + * assumed to be the empty string. + */ + file_construct_ref(filename, usage, gameid) + { + if (!filename) + filename = ''; + if (!usage) + usage = ''; + if (!gameid) + gameid = ''; + var path = path_mod.join(this.extfilepath, filename); + var ref = { filename:path, usage:usage }; + return ref; + } + + /* Dialog.file_construct_temp_ref(usage) + * + * Create a fileref in a temporary directory. Every time this is called + * it should create a completely new fileref. + */ + file_construct_temp_ref(usage) + { + var timestamp = new Date().getTime(); + var filename = "_temp_" + timestamp + "_" + Math.random(); + filename = filename.replace('.', ''); + var temppath = this.get_temp_path(); + var path = path_mod.join(temppath, filename); + var ref = { filename:path, usage:usage }; + return ref; + } + + /* Dialog.file_fopen(fmode, ref) -- open a file for reading or writing + * + * Returns an FStream object. + */ + file_fopen(fmode, ref) + { + /* This object is analogous to a FILE* in C code. Yes, we're + reimplementing fopen() for Node.js. I'm not proud. Or tired. + The good news is, the logic winds up identical to that in + the C libraries. + */ + var fstream = new FStream(fmode, ref.filename); + + /* The spec says that Write, ReadWrite, and WriteAppend create the + file if necessary. However, open(filename, "r+") doesn't create + a file. So we have to pre-create it in the ReadWrite and + WriteAppend cases. (We use "a" so as not to truncate.) */ + + if (fmode == filemode_ReadWrite || fmode == filemode_WriteAppend) { + try { + var tempfd = fs.openSync(fstream.filename, "a"); + fs.closeSync(tempfd); + } + catch (ex) { + this.log('file_fopen: failed to open ' + fstream.filename + ': ' + ex); + return null; + } + } + + /* Another Unix quirk: in r+ mode, you're not supposed to flip from + reading to writing or vice versa without doing an fseek. We will + track the most recent operation (as lastop) -- Write, Read, or + 0 if either is legal next. */ + + var modestr = null; + switch (fmode) { + case filemode_Write: + modestr = "w"; + break; + case filemode_Read: + modestr = "r"; + break; + case filemode_ReadWrite: + modestr = "r+"; + break; + case filemode_WriteAppend: + /* Can't use "a" here, because then fseek wouldn't work. + Instead we use "r+" and then fseek to the end. */ + modestr = "r+"; + break; + } + + try { + fstream.fd = fs.openSync(fstream.filename, modestr); + } + catch (ex) { + this.log('file_fopen: failed to open ' + fstream.filename + ': ' + ex); + return null; + } + + if (fmode == filemode_WriteAppend) { + /* We must jump to the end of the file. */ + try { + var stats = fs.fstatSync(fstream.fd); + fstream.mark = stats.size; + } + catch (ex) {} + } + + return fstream; + } + + /* Dialog.file_ref_exists(ref) -- returns whether the file exists + */ + file_ref_exists(ref) + { + try { + fs.accessSync(ref.filename, fs.F_OK); + return true; + } + catch (ex) { + return false; + } + } + + /* Dialog.file_remove_ref(ref) -- delete the file, if it exists + */ + file_remove_ref(ref) + { + try { + fs.unlinkSync(ref.filename); + } + catch (ex) { } + } + + /* Dialog.file_read(dirent, israw) -- read data from the file + * + * This call is intended for the non-streaming API, so it does not + * exist in this version of Dialog. + */ + file_read(dirent, israw) + { + throw new Error('file_read not implemented in electrofs'); + } + + /* Dialog.file_write(dirent, content, israw) -- write data to the file + * + * This call is intended for the non-streaming API, so it does not + * exist in this version of Dialog. + */ + file_write(dirent, content, israw) + { + throw new Error('file_write not implemented in electrofs'); + } + + /* Construct a file-filter list for a given usage type. These lists are + used by showOpenDialog and showSaveDialog, below. + */ + filters_for_usage(val) + { + switch (val) { + case 'data': + return [ { name: 'Glk Data File', extensions: ['glkdata'] } ]; + case 'save': + return [ { name: 'Glk Save File', extensions: ['glksave'] } ]; + case 'transcript': + return [ { name: 'Transcript File', extensions: ['txt'] } ]; + case 'command': + return [ { name: 'Command File', extensions: ['txt'] } ]; + default: + return []; + } + } + + get_temp_path() { + return os.tmpdir(); + } + + get_user_path() { + throw new Error('Method not implemented'); + } + + log(message) { + console.log(message); + } + + open() { + throw new Error('Method not implemented'); + } +} + +class ElectronDialog extends Dialog { + get_temp_path() { + return require('electron').remote.app.getPath('temp'); + } + + get_user_path() { + return require('electron').remote.app.getPath('userData'); + } + + log(message) { + GlkOte.log(message); + } + + /* Dialog.open(tosave, usage, gameid, callback) -- open a file-choosing dialog + * + * The "tosave" flag should be true for a save dialog, false for a load + * dialog. + * + * The "usage" and "gameid" arguments are arbitrary strings which describe the + * file. These filter the list of files displayed; the dialog will only list + * files that match the arguments. Pass null to either argument (or both) to + * skip filtering. + * + * The "callback" should be a function. This will be called with a fileref + * argument (see below) when the user selects a file. If the user cancels the + * selection, the callback will be called with a null argument. + */ + open(tosave, usage, gameid, callback) + { + const dialog = require('electron').remote.dialog; + var opts = { + filters: this.filters_for_usage(usage) + }; + var mainwin = require('electron').remote.getCurrentWindow(); + if (!tosave) { + opts.properties = ['openFile']; + dialog.showOpenDialog(mainwin, opts, function(ls) { + if (!ls || !ls.length) { + callback(null); + } + else { + var ref = { filename:ls[0], usage:usage }; + callback(ref); + } + }); + } + else { + dialog.showSaveDialog(mainwin, opts, function(path) { + if (!path) { + callback(null); + } + else { + var ref = { filename:path, usage:usage }; + callback(ref); + } + }); + } + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports.Dialog = Dialog; + module.exports.ElectronDialog = ElectronDialog; +} + +/* End of Dialog namespace function. Return the object which will + become the Dialog global. */ +try { + return new ElectronDialog(); +} +catch (e) {} + +}(); + +/* End of Dialog library. */ \ No newline at end of file diff --git a/src/glkapi.js b/src/glkapi.js index c2abef2..382e7dd 100644 --- a/src/glkapi.js +++ b/src/glkapi.js @@ -45,8 +45,14 @@ Glk = function() { /* The VM interface object. */ var VM = null; -/* Environment capabilities. (Checked at init time.) */ -var has_canvas; +/* References to external libraries */ +var Dialog; +var GiDispa; +var GiLoad; +var GlkOte; + +/* Environment capabilities */ +var support = {}; /* Options from the vm_options object. */ var option_exit_warning; @@ -79,11 +85,43 @@ var current_partial_outputs = null; library sets that up for you.) */ function init(vm_options) { - /* Check for canvas support. We don't rely on jquery here. */ - has_canvas = (document.createElement('canvas').getContext != undefined); + /* Set references to external libraries */ + if (vm_options.Dialog) { + Dialog = vm_options.Dialog; + } + else if (typeof window !== 'undefined' && window.Dialog) { + Dialog = window.Dialog; + } + else { + throw new Error('No reference to Dialog'); + } + + if (vm_options.GiDispa) { + GiDispa = vm_options.GiDispa; + } + else if (typeof window !== 'undefined' && window.GiDispa) { + GiDispa = window.GiDispa; + } + + if (vm_options.GiLoad) { + GiLoad = vm_options.GiLoad; + } + else if (typeof window !== 'undefined' && window.GiLoad) { + GiLoad = window.GiLoad; + } + + if (vm_options.GlkOte) { + GlkOte = vm_options.GlkOte; + } + else if (typeof window !== 'undefined' && window.GlkOte) { + GlkOte = window.GlkOte; + } + else { + throw new Error('No reference to GlkOte'); + } VM = vm_options.vm; - if (window.GiDispa) + if (GiDispa) GiDispa.set_vm(VM); vm_options.accept = accept_ui_event; @@ -125,8 +163,10 @@ function accept_ui_event(obj) { switch (obj.type) { case 'init': content_metrics = obj.metrics; - /* We ignore the support array. This library is updated in sync - with GlkOte, so we know what it supports. */ + /* Process the support array */ + if (obj.support) { + obj.support.forEach(function(item) {support[item] = 1;}); + } VM.init(); break; @@ -202,7 +242,7 @@ function handle_arrange_input() { gli_selectref.set_field(2, 0); gli_selectref.set_field(3, 0); - if (window.GiDispa) + if (GiDispa) GiDispa.prepare_resume(gli_selectref); gli_selectref = null; VM.resume(); @@ -217,7 +257,7 @@ function handle_redraw_input() { gli_selectref.set_field(2, 0); gli_selectref.set_field(3, 0); - if (window.GiDispa) + if (GiDispa) GiDispa.prepare_resume(gli_selectref); gli_selectref = null; VM.resume(); @@ -240,7 +280,7 @@ function handle_external_input(res) { gli_selectref.set_field(2, val1); gli_selectref.set_field(3, val2); - if (window.GiDispa) + if (GiDispa) GiDispa.prepare_resume(gli_selectref); gli_selectref = null; VM.resume(); @@ -265,7 +305,7 @@ function handle_hyperlink_input(disprock, val) { win.hyperlink_request = false; - if (window.GiDispa) + if (GiDispa) GiDispa.prepare_resume(gli_selectref); gli_selectref = null; VM.resume(); @@ -290,7 +330,7 @@ function handle_mouse_input(disprock, xpos, ypos) { win.mouse_request = false; - if (window.GiDispa) + if (GiDispa) GiDispa.prepare_resume(gli_selectref); gli_selectref = null; VM.resume(); @@ -330,7 +370,7 @@ function handle_char_input(disprock, input) { win.char_request_uni = false; win.input_generation = null; - if (window.GiDispa) + if (GiDispa) GiDispa.prepare_resume(gli_selectref); gli_selectref = null; VM.resume(); @@ -377,7 +417,7 @@ function handle_line_input(disprock, input, termkey) { gli_selectref.set_field(2, input.length); gli_selectref.set_field(3, termcode); - if (window.GiDispa) + if (GiDispa) GiDispa.unretain_array(win.linebuf); win.line_request = false; win.line_request_uni = false; @@ -385,7 +425,7 @@ function handle_line_input(disprock, input, termkey) { win.input_generation = null; win.linebuf = null; - if (window.GiDispa) + if (GiDispa) GiDispa.prepare_resume(gli_selectref); gli_selectref = null; VM.resume(); @@ -1158,6 +1198,9 @@ var Const = { evtype_Hyperlink : 8, evtype_VolumeNotify : 9, + /* A fake event for fileref_create_by_prompt */ + evtype_FilerefPromp : -1, + style_Normal : 0, style_Emphasized : 1, style_Preformatted : 2, @@ -2631,10 +2674,8 @@ function UniArrayToBE32(arr) { up in Safari, in Opera, and in Firefox if you have Firebug installed.) */ function qlog(msg) { - if (window.console && console.log) + if (typeof console !== 'undefined' && console.log) console.log(msg); - else if (window.opera && opera.postError) - opera.postError(msg); } /* RefBox: Simple class used for "call-by-reference" Glk arguments. The object @@ -2764,7 +2805,7 @@ function gli_new_window(type, rock) { if (win.next) win.next.prev = win; - if (window.GiDispa) + if (GiDispa) GiDispa.class_register('window', win); else win.disprock = gli_api_display_rocks++; @@ -2778,7 +2819,7 @@ function gli_new_window(type, rock) { function gli_delete_window(win) { var prev, next; - if (window.GiDispa) + if (GiDispa) GiDispa.class_unregister('window', win); geometry_changed = true; @@ -2998,7 +3039,7 @@ function gli_window_close(win, recurse) { } } - if (window.GiDispa && win.linebuf) { + if (GiDispa && win.linebuf) { GiDispa.unretain_array(win.linebuf); win.linebuf = null; } @@ -3232,7 +3273,7 @@ function gli_new_stream(type, readable, writable, rock) { if (str.next) str.next.prev = str; - if (window.GiDispa) + if (GiDispa) GiDispa.class_register('stream', str); return str; @@ -3248,7 +3289,7 @@ function gli_delete_stream(str) { gli_windows_unechostream(str); if (str.type == strtype_Memory) { - if (window.GiDispa) + if (GiDispa) GiDispa.unretain_array(str.buf); } else if (str.type == strtype_File) { @@ -3258,7 +3299,7 @@ function gli_delete_stream(str) { } } - if (window.GiDispa) + if (GiDispa) GiDispa.class_unregister('stream', str); prev = str.prev; @@ -3356,7 +3397,7 @@ function gli_new_fileref(filename, usage, rock, ref) { if (fref.next) fref.next.prev = fref; - if (window.GiDispa) + if (GiDispa) GiDispa.class_register('fileref', fref); return fref; @@ -3365,7 +3406,7 @@ function gli_new_fileref(filename, usage, rock, ref) { function gli_delete_fileref(fref) { var prev, next; - if (window.GiDispa) + if (GiDispa) GiDispa.class_unregister('fileref', fref); prev = fref.prev; @@ -4016,6 +4057,7 @@ function glk_exit() { gli_selectref = null; if (option_exit_warning) GlkOte.warning(option_exit_warning); + GlkOte.update({ type: 'exit' }); return DidNotReturn; } @@ -4072,22 +4114,20 @@ function glk_gestalt_ext(sel, val, arr) { return 2; // gestalt_CharOutput_ExactPrint case 4: // gestalt_MouseInput - if (val == Const.wintype_TextBuffer) + if (val == Const.wintype_TextGrid) return 1; - if (val == Const.wintype_Graphics && has_canvas) + if (support.graphics && val == Const.wintype_Graphics) return 1; return 0; case 5: // gestalt_Timer - return 1; + return support.timer || 0; case 6: // gestalt_Graphics - return 1; + return support.graphics || 0; case 7: // gestalt_DrawImage - if (val == Const.wintype_TextBuffer) - return 1; - if (val == Const.wintype_Graphics && has_canvas) + if (support.graphics && (val == Const.wintype_TextBuffer || val == Const.wintype_Graphics)) return 1; return 0; @@ -4101,10 +4141,10 @@ function glk_gestalt_ext(sel, val, arr) { return 0; case 11: // gestalt_Hyperlinks - return 1; + return support.hyperlinks || 0; case 12: // gestalt_HyperlinkInput - if (val == 3 || val == 4) // TextBuffer or TextGrid + if (support.hyperlinks && (val == Const.wintype_TextBuffer || val == Const.wintype_TextGrid)) return 1; else return 0; @@ -4113,7 +4153,7 @@ function glk_gestalt_ext(sel, val, arr) { return 0; case 14: // gestalt_GraphicsTransparency - return 1; + return support.graphics || 0; case 15: // gestalt_Unicode return 1; @@ -4252,7 +4292,7 @@ function glk_window_open(splitwin, method, size, wintype, rock) { newwin.cursory = 0; break; case Const.wintype_Graphics: - if (!has_canvas) { + if (!support.graphics) { /* Graphics windows not supported; silently return null */ gli_delete_window(newwin); return null; @@ -4724,7 +4764,7 @@ function glk_stream_open_memory(buf, fmode, rock) { str.bufeof = 0; else str.bufeof = str.buflen; - if (window.GiDispa) + if (GiDispa) GiDispa.retain_array(buf); } @@ -4734,7 +4774,7 @@ function glk_stream_open_memory(buf, fmode, rock) { function glk_stream_open_resource(filenum, rock) { var str; - if (!window.GiLoad || !GiLoad.find_data_chunk) + if (!GiLoad || !GiLoad.find_data_chunk) return null; var el = GiLoad.find_data_chunk(filenum); if (!el) @@ -4773,7 +4813,7 @@ function glk_stream_open_resource(filenum, rock) { function glk_stream_open_resource_uni(filenum, rock) { var str; - if (!window.GiLoad || !GiLoad.find_data_chunk) + if (!GiLoad || !GiLoad.find_data_chunk) return null; var el = GiLoad.find_data_chunk(filenum); if (!el) @@ -4962,7 +5002,15 @@ function gli_fileref_create_by_prompt_callback(obj) { ui_specialinput = null; ui_specialcallback = null; - if (window.GiDispa) + /* If glk_select() was called after glk_fileref_create_by_prompt() then + return fref via the RefStruct rather than through SetResumeStore() */ + if (gli_selectref) { + gli_selectref.set_field(0, Const.evtype_FilerefPromp); + gli_selectref.set_field(1, fref); + gli_selectref = null; + } + + if (GiDispa) GiDispa.prepare_resume(fref); VM.resume(); } @@ -5151,7 +5199,7 @@ function glk_request_line_event(win, buf, initlen) { win.request_echo_line_input = true; win.input_generation = event_generation; win.linebuf = buf; - if (window.GiDispa) + if (GiDispa) GiDispa.retain_array(buf); } else { @@ -5207,7 +5255,7 @@ function glk_cancel_line_event(win, eventref) { eventref.set_field(3, 0); } - if (window.GiDispa) + if (GiDispa) GiDispa.unretain_array(win.linebuf); win.line_request = false; win.line_request_uni = false; @@ -5303,7 +5351,7 @@ function glk_request_timer_events(msec) { /* Graphics functions. */ function glk_image_get_info(imgid, widthref, heightref) { - if (!window.GiLoad || !GiLoad.get_image_info) + if (!GiLoad || !GiLoad.get_image_info) return null; var info = GiLoad.get_image_info(imgid); @@ -5325,7 +5373,7 @@ function glk_image_draw(win, imgid, val1, val2) { if (!win) throw('glk_image_draw: invalid window'); - if (!window.GiLoad || !GiLoad.get_image_info) + if (!GiLoad || !GiLoad.get_image_info) return 0; var info = GiLoad.get_image_info(imgid); if (!info) @@ -5375,7 +5423,7 @@ function glk_image_draw_scaled(win, imgid, val1, val2, width, height) { if (!win) throw('glk_image_draw_scaled: invalid window'); - if (!window.GiLoad || !GiLoad.get_image_info) + if (!GiLoad || !GiLoad.get_image_info) return 0; var info = GiLoad.get_image_info(imgid); if (!info) @@ -5980,7 +6028,7 @@ function glk_stream_open_memory_uni(buf, fmode, rock) { str.bufeof = 0; else str.bufeof = str.buflen; - if (window.GiDispa) + if (GiDispa) GiDispa.retain_array(buf); } @@ -6028,7 +6076,7 @@ function glk_request_line_event_uni(win, buf, initlen) { win.request_echo_line_input = true; win.input_generation = event_generation; win.linebuf = buf; - if (window.GiDispa) + if (GiDispa) GiDispa.retain_array(buf); } else { @@ -6175,7 +6223,7 @@ function glk_date_to_simple_time_local(dateref, factor) { /* End of Glk namespace function. Return the object which will become the Glk global. */ -return { +var api = { version: '2.2.3', /* GlkOte/GlkApi version */ init : init, update : update, @@ -6318,6 +6366,12 @@ return { glk_stream_open_resource_uni : glk_stream_open_resource_uni }; +if (typeof module !== 'undefined' && module.exports) { + module.exports = api; +} + +return api; + }(); /* End of Glk library. */ \ No newline at end of file diff --git a/src/glkote-dumb.js b/src/glkote-dumb.js index 125aa6c..cf6bd7d 100644 --- a/src/glkote-dumb.js +++ b/src/glkote-dumb.js @@ -13,8 +13,10 @@ https://github.com/curiousdannii/glkote-term const ansiEscapes = require( 'ansi-escapes' ) const MuteStream = require( 'mute-stream' ) +const os = require( 'os' ) const readline = require( 'readline' ) +const Dialog = require( './electrofs.js' ) const GlkOte = require( './glkote-term.js' ) const key_replacements = { @@ -26,6 +28,13 @@ const stdin = process.stdin const stdout = new MuteStream() stdout.pipe( process.stdout ) +// Create this now so that both DumbGlkOte and DumbDialog can access it, even though it may not be used +const rl = readline.createInterface({ + input: stdin, + output: stdout, + prompt: '', +}) + class DumbGlkOte extends GlkOte { init( iface ) @@ -59,12 +68,7 @@ class DumbGlkOte extends GlkOte stdin.setRawMode( true ) } readline.emitKeypressEvents( stdin ) - this.input = readline.createInterface({ - input: stdin, - output: stdout, - prompt: '', - }) - this.input.resume() + rl.resume() // Event callbacks this.handle_char_input_callback = ( str, key ) => this.handle_char_input( str, key ) @@ -74,6 +78,29 @@ class DumbGlkOte extends GlkOte super.init( iface ) } + accept_specialinput( data ) + { + if ( data.type === 'fileref_prompt' ) + { + const replyfunc = ( ref ) => this.send_response( 'specialresponse', null, 'fileref_prompt', ref ) + try + { + ( new DumbDialog() ).open( data.filemode !== 'read', data.filetype, data.gameid, replyfunc ) + } + catch (ex) + { + this.log( 'Unable to open file dialog: ' + ex ) + /* Return a failure. But we don't want to call send_response before + glkote_update has finished, so we defer the reply slightly. */ + setImmediate( () => replyfunc( null ) ) + } + } + else + { + this.error( 'Request for unknown special input type: ' + data.type ) + } + } + attach_handlers() { if ( this.current_input_type === 'char' ) @@ -83,7 +110,7 @@ class DumbGlkOte extends GlkOte } if ( this.current_input_type === 'line' ) { - this.input.on( 'line', this.handle_line_input_callback ) + rl.on( 'line', this.handle_line_input_callback ) } } @@ -99,7 +126,7 @@ class DumbGlkOte extends GlkOte detach_handlers() { stdin.removeListener( 'keypress', this.handle_char_input_callback ) - this.input.removeListener( 'line', this.handle_line_input_callback ) + rl.removeListener( 'line', this.handle_line_input_callback ) stdout.unmute() } @@ -118,7 +145,7 @@ class DumbGlkOte extends GlkOte exit() { - this.input.close() + rl.close() stdout.write( '\n' ) super.exit() } @@ -131,8 +158,8 @@ class DumbGlkOte extends GlkOte this.detach_handlers() // Make sure this char isn't being remembered for the next line input - this.input._line_buffer = null - this.input.line = '' + rl._line_buffer = null + rl.line = '' // Process special keys const res = key_replacements[str] || str || key.name.replace( /f(\d+)/, 'func$1' ) @@ -183,20 +210,23 @@ class DumbGlkOte extends GlkOte update_inputs( data ) { - if ( data[0].type === 'char' ) + if ( data.length ) { - this.current_input_type = 'char' - } + if ( data[0].type === 'char' ) + { + this.current_input_type = 'char' + } - if ( data[0].type === 'line' ) - { - if ( stdout.isTTY ) + if ( data[0].type === 'line' ) { - stdout.write( ansiEscapes.cursorSavePosition ) + if ( stdout.isTTY ) + { + stdout.write( ansiEscapes.cursorSavePosition ) + } + this.current_input_type = 'line' } - this.current_input_type = 'line' + this.attach_handlers() } - this.attach_handlers() } update_windows( data ) @@ -205,4 +235,34 @@ class DumbGlkOte extends GlkOte } } +class DumbDialog extends Dialog.Dialog +{ + get_user_path() + { + return os.homedir() + } + + log() + {} + + open( tosave, usage, gameid, callback ) + { + rl.question( 'Please enter a file name (without an extension): ', ( path ) => + { + if ( !path ) + { + callback( null ) + } + else + { + callback({ + filename: path + '.' + this.filters_for_usage( usage )[0].extensions[0], + usage: usage, + }) + } + }) + } +} + module.exports = DumbGlkOte +module.exports.Dialog = DumbDialog diff --git a/src/glkote-term.js b/src/glkote-term.js index fb4bbc2..98331b0 100644 --- a/src/glkote-term.js +++ b/src/glkote-term.js @@ -91,7 +91,7 @@ class GlkOte { this.update_windows( data.windows ) } - if ( data.content != null ) + if ( data.content != null && data.content.length ) { this.update_content( data.content ) } @@ -100,6 +100,11 @@ class GlkOte this.update_inputs( data.input ) } + if ( data.specialinput != null ) + { + this.accept_specialinput( data.specialinput ) + } + // Disable everything if requested this.disabled = false if ( data.disabled || data.specialinput ) @@ -152,7 +157,7 @@ class GlkOte return metrics } - send_response( type, win, val /*, val2*/ ) + send_response( type, win, val, val2 ) { const res = { type: type, @@ -184,6 +189,12 @@ class GlkOte res.value = val } + if ( type === 'specialresponse' ) + { + res.response = val + res.value = val2 + } + this.interface.accept( res ) } diff --git a/tests/zvm.js b/tests/zvm.js index f890f71..57cfb05 100755 --- a/tests/zvm.js +++ b/tests/zvm.js @@ -13,6 +13,7 @@ const Glk = GlkOte.Glk const options = { vm: vm, + Dialog: new GlkOte.Dialog(), Glk: Glk, GlkOte: new GlkOte(), }