diff --git a/Nasal/canvas/api.nas b/Nasal/canvas/api.nas index 11eac9105..a1f346292 100644 --- a/Nasal/canvas/api.nas +++ b/Nasal/canvas/api.nas @@ -509,6 +509,10 @@ var Text = { { me.set("text", typeof(text) == 'scalar' ? text : ""); }, + appendText: func(text) + { + me.set("text", (me.get("text") or "") ~ (typeof(text) == 'scalar' ? text : "")); + }, # Set alignment # # @param align String, one of: @@ -544,10 +548,10 @@ var Text = { me.set("font", name); }, # Enumeration of values for drawing mode: - TEXT: 1, # The text itself - BOUNDINGBOX: 2, # A bounding box (only lines) - FILLEDBOUNDINGBOX: 4, # A filled bounding box - ALIGNMENT: 8, # Draw a marker (cross) at the position of the text + TEXT: 0x01, # The text itself + BOUNDINGBOX: 0x02, # A bounding box (only lines) + FILLEDBOUNDINGBOX: 0x04, # A filled bounding box + ALIGNMENT: 0x08, # Draw a marker (cross) at the position of the text # Set draw mode. Binary combination of the values above. Since I haven't found # a bitwise or we have to use a + instead. # diff --git a/Nasal/canvas/gui/styles/DefaultStyle.nas b/Nasal/canvas/gui/styles/DefaultStyle.nas index 248ead03c..ea720ff92 100644 --- a/Nasal/canvas/gui/styles/DefaultStyle.nas +++ b/Nasal/canvas/gui/styles/DefaultStyle.nas @@ -99,6 +99,12 @@ DefaultStyle.widgets["scroll-area"] = { me.vert = me._newScroll(me.element, "vert"); me.horiz = me._newScroll(me.element, "horiz"); }, + setColorBackground: func + { + if( size(arg) == 1 ) + var arg = arg[0]; + me._bg.setColorFill(arg); + }, update: func(widget) { me.horiz.reset(); diff --git a/Nasal/canvas/gui/widgets/ScrollArea.nas b/Nasal/canvas/gui/widgets/ScrollArea.nas index 0819af0a7..4be78e877 100644 --- a/Nasal/canvas/gui/widgets/ScrollArea.nas +++ b/Nasal/canvas/gui/widgets/ScrollArea.nas @@ -7,6 +7,8 @@ gui.widgets.ScrollArea = { m._active = 0; m._pos = [0,0]; m._size = cfg.get("size", m._size); + m._max_scroll = [0, 0]; + m._content_size = [0, 0]; if( style != nil ) { @@ -34,35 +36,79 @@ gui.widgets.ScrollArea = { { return me._scroll.content; }, + # Set the background color for the content area. + # + # @param color Vector of 3 or 4 values in [0, 1] + setColorBackground: func + { + if( size(arg) == 1 ) + var arg = arg[0]; + me._scroll.setColorBackground(arg); + }, + # Reset the size of the content area, e.g. on window resize. + # + # @param sz Vector of [x,y] values. + setSize: func + { + if( size(arg) == 1 ) + var arg = arg[0]; + var (x,y) = arg; + me._size = [x,y]; + me.update(); + }, + # Move the scrollable area to the coordinates x,y (or as far as possible) and + # update. + # + # @param x The x coordinate (positive is right) + # @param y The y coordinate (positive is down) moveTo: func(x, y) { + var bb = me._updateBB(); + me._pos[0] = math.max(0, math.min(x, me._max_scroll[0])); me._pos[1] = math.max(0, math.min(y, me._max_scroll[1])); + me.update(bb); + }, + # Move the scrollable area to the top-most position and update. + moveToTop: func() + { + me._pos[1] = 0; + me.update(); }, - update: func() + # Move the scrollable area to the bottom-most position and update. + moveToBottom: func() { - # TODO only update on content resize - var bb = me.getContent().getTightBoundingBox(); + var bb = me._updateBB(); - if( bb[2] < bb[0] or bb[3] < bb[1] ) - # Do nothing with invalid bounding box (probably no content yet) - return me; + me._pos[1] = me._max_scroll[1]; - var w = bb[2] - bb[0]; - var h = bb[3] - bb[1]; + me.update(bb); + }, + # Move the scrollable area to the left-most position and update. + moveToLeft: func() + { + me._pos[0] = 0; - me._max_scroll = [0, 0]; - if( w > me._size[0] ) - me._max_scroll[0] = me._size[0] * (1 - me._size[0] / w); - if( h > me._size[1] ) - me._max_scroll[1] = me._size[1] * (1 - me._size[1] / h); + me.update(); + }, + # Move the scrollable area to the right-most position and update. + moveToRight: func() + { + var bb = me._updateBB(); - me._content_size = [w, h]; + me._pos[0] = me._max_scroll[0]; - var cur_offset = me.getContent().getTranslation(); - me._content_offset = [cur_offset[0] - bb[0], cur_offset[1] - bb[1]]; + me.update(bb); + }, + # Update scroll bar and content area. + # + # Needs to be called when the size of the content changes. + update: func(bb=nil) + { + if (bb == nil) bb = me._updateBB(); + if (bb == nil) return me; var offset = [ me._content_offset[0], me._content_offset[1] ]; @@ -78,6 +124,8 @@ gui.widgets.ScrollArea = { me._scroll.update(me); me.getContent().update(); + + return me; }, # protected: _setRoot: func(el) @@ -90,5 +138,27 @@ gui.widgets.ScrollArea = { { me._drag_offsetX = me._pos[0] - e.clientX; me._drag_offsetY = me._pos[1] - e.clientY; - } + }, + _updateBB: func() { + # TODO only update on content resize + var bb = me.getContent().getTightBoundingBox(); + + if( bb[2] < bb[0] or bb[3] < bb[1] ) + return nil; + var w = bb[2] - bb[0]; + var h = bb[3] - bb[1]; + + if( w > me._size[0] ) + me._max_scroll[0] = me._size[0] * (1 - me._size[0] / w); + else me._max_scroll[0] = 0; + if( h > me._size[1] ) + me._max_scroll[1] = me._size[1] * (1 - me._size[1] / h); + else me._max_scroll[1] = 0; + + me._content_size[0] = w; + me._content_size[1] = h; + + var cur_offset = me.getContent().getTranslation(); + me._content_offset = [cur_offset[0] - bb[0], cur_offset[1] - bb[1]]; + }, }; diff --git a/Nasal/console/repl.nas b/Nasal/console/repl.nas new file mode 100644 index 000000000..23165ff20 --- /dev/null +++ b/Nasal/console/repl.nas @@ -0,0 +1,709 @@ +var nocolor = { + start: func { + me.prev = (string.color("1", "2") != "2"); + string.setcolors(0); + }, end: func() { + string.setcolors(me.prev); + }, +}; + +var _REPL_dbg_level = "debug"; +#var _REPL_dbg_level = "alert"; + +var REPL = { + df_status: 0, + whitespace: [" ", "\t", "\n", "\r"], + end_statement: [";", ","], + statement_types: [ + "for", "foreach", "forindex", + "while", "else", "func", "if", "elsif" + ], + operators_binary_unary: [ + "~", "+", "-", "*", "/", + "!", "?", ":", ".", ",", + "<", ">", "=" + ], + brackets: { + "(":")", + "[":"]", + "{":"}", + }, + str_chars: ["'", '"', "`"], + brackets_rev: {}, + brackets_start: [], brackets_end: [], + new: func(placement, name="", keep_history=1, namespace=nil) { + if (namespace == nil) namespace = {}; + elsif (typeof(namespace) == 'scalar') namespace = globals[namespace]; + if (typeof(namespace) != 'hash') die("bad namespace!"); + var m = { + parents: [REPL], + placement: placement, + name: name, + keep_history: keep_history, + namespace: namespace, + history: [], + current: nil, + }; + return m; + }, + execute: func() { + var code = string.join("\n", me.current.line); + + me.current = nil; + + printlog(_REPL_dbg_level, "compiling code..."~debug.string(code)); + + var fn = call(func compile(code, me.name), nil, var err=[]); + if (size(err)) { + var msg = err[0]; + var prefix = "Parse error: "; + if (substr(msg, 0, size(prefix)) == prefix) + msg = substr(msg, size(prefix)); + var (msg, line) = split(" at line ", msg); + #debug.dump(err); + me.placement.handle_parse_error(msg, me.name, line); # message, (file)name, line number + return 0; + } + var res = call(bind(fn, globals), nil, nil, me.namespace, err); + if (size(err)) { + me.placement.handle_runtime_error(err); # err vec + return 0; + } + me.placement.display_result(res); + return 1; + }, + _is_str_char: func(char) { + foreach (var c; me.str_chars) + if (c == char or c[0] == char) return 1; + return 0; + }, + _handle_level: func(level, str, line_number) { + if (size(str) != 1) + var str = substr(str, 0, 1); + if (contains(me.brackets, str)) { + append(level, str); + printlog(_REPL_dbg_level, "> level add "~str); + return 1; + } elsif (contains(me.brackets_rev, str)) { + var l = pop(level); + if (l == nil) { + me.placement.handle_parse_error("extra closing bracket "'"'~str~'"', me.name, line_number); + return nil; + } elsif (me.brackets[l] != str) { + me.placement.handle_parse_error("bracket mismatch: "~me.brackets[l]~" vs "~str, me.name, line_number); + return nil; + } else { + printlog(_REPL_dbg_level, "< level pop "~str); + return 1; + } + } + return 0; + }, + get_input: func() { + var line = me.placement.get_line(); + if (line == nil or string.trim(line) == "") return me.df_status; + var len = size(line); + if (me.current == nil) + me.current = { + line: [], + brackets: [], + level: [], + statement: nil, + statement_level: nil, + last_operator: nil, + }; + for (var i=0; i size(me.current.level)) { + printlog(_REPL_dbg_level, "statement ended by level below"); + # cancel out of statement + me.current.statement = nil; + me.current.statement_level = nil; + } elsif (line[i] == `{`) { + # cancel out of looking for `;`, because we have a real block here + printlog(_REPL_dbg_level, "statement ended by braces"); + me.current.statement = nil; + me.current.statement_level = nil; + } + } + continue; + } elsif (string.isalpha(line[i])) { + me.current.last_operator = nil; + foreach (var stmt; me.statement_types) { + if (substr(line, i, size(stmt)) == stmt and + (i+size(stmt) >= len + or !string.isalnum(line[i+size(stmt)]) + and line[i+size(stmt)] != `_`)) { + printlog(_REPL_dbg_level, "found: "~stmt); + me.current.statement = stmt; + me.current.statement_level = size(me.current.level); + i += size(stmt)-1; + break; + } + } + } elsif (me._is_str_char(line[i])) { + me.current.last_operator = nil; + append(me.current.level, chr(line[i])); + printlog(_REPL_dbg_level, "> into string with "~me.current.level[-1]); + } else { + var ret = me._handle_level(me.current.level, chr(line[i]), size(me.current.line)+1); + me.current.last_operator = nil; + if (ret == nil) # error + return 0; + elsif (ret == 0) { + foreach (var o; me.operators_binary_unary) + if (line[i] == o[0]) + { me.current.last_operator = o; printlog(_REPL_dbg_level, "found operator "~o); break } + } + } + } + append(me.current.line, line); + if (me.keep_history) + append(me.history, { + type: "input", + line: line, + }); + var execute = (me.current.statement == nil and me.current.last_operator == nil and !size(me.current.level)); + if (execute) { + me.df_status = 0; + return me.execute(); + } else + return (me.df_status = -1); + }, +}; +foreach (var b; keys(REPL.brackets)) { + var v = REPL.brackets[b]; + append(REPL.brackets_start, b); + append(REPL.brackets_end, v); + REPL.brackets_rev[v] = b; +} + +var CanvasPlacement = { + instances: [], + current_instance: nil, + keys: [ + "ESC", "Exit/close this dialog", + "Ctrl-d", "Same as ESC", + "Ctrl-v", "Insert text (at the end of the current line)", + "Ctrl-c", "Copy the current line of text", + "Ctrl-x", "Copy and delete the current line of text", + "Up", "Previous line in history", + "Down", "Next line in history", + "Left", nil, + "Right", nil, + "Shift+Left", nil, + "Shift+Right", nil, + ], + translations: { + "bad-result": "[Error: cannot display output]", + "key-not-mapped": "[Not Implemented]", + "help": "Welcome to the Nasal REPL Interpreter. Press any key to " + "exit this message, ESC to exit the dialog (at any time " + "afterwards), and type away to test code :).\n\nNote: " + "this dialog will capture nearly all key-presses, so don't " + "try to fly with the keyboard while this is open!" + "\n\nImportant keys:", + }, + styles: { + "default": { + size: [600, 300], + separate_lines: 1, + window_style: "default", + padding: 5, + max_output_chars: 200, + colors: { + # TODO: integrate colors from debug.nas? + text: [1,1,1], + text_fill: nil, + background: [0.1,0.06,0.4,0.3], + error: [1,0.2,0.1], + }, + font_size: 17, + font_file: "LiberationFonts/LiberationMono-Bold.ttf", + font_aspect_ratio: 1.5, + font_max_width: nil, + }, + "transparent-blue": { + size: [600, 300], + separate_lines: 1, + window_style: nil, + padding: 5, + max_output_chars: 200, + colors: { + text: [1,1,1], + text_fill: nil, + background: [0.1,0.06,0.4,0.3], + error: [1,0.2,0.1], + }, + font_size: 17, + font_file: "LiberationFonts/LiberationMono-Bold.ttf", + font_aspect_ratio: 1.5, + font_max_width: nil, + }, + "transparent-red": { + size: [600, 300], + separate_lines: 1, + window_style: nil, + padding: 5, + max_output_chars: 200, + colors: { + text: [1,1,1], + text_fill: nil, + background: [0.8,0.06,0.07,0.4], + error: [1,0.2,0.1], + }, + font_size: 17, + font_file: "LiberationFonts/LiberationMono-Bold.ttf", + font_aspect_ratio: 1.5, + font_max_width: nil, + }, + "canvas-default": { + size: [600, 300], + separate_lines: 1, + window_style: "default", + padding: 5, + max_output_chars: 87, + colors: { + text: [0.8,0.86,0.8], + text_fill: nil, + background: [0.05,0.03,0.2], + error: [1,0.2,0.1], + }, + font_size: 17, + font_file: "LiberationFonts/LiberationMono-Bold.ttf", + font_aspect_ratio: 1.5, + font_max_width: nil, + #font_max_width: 588, + }, + }, + new: func(name="", style="canvas-default") { + if (typeof(style) == 'scalar') { + style = CanvasPlacement.styles[style]; + } + if (typeof(style) != 'hash') die("bad style"); + var m = { + parents: [CanvasPlacement, style], + state: "startup", + listeners: [], + window: canvas.Window.new(style.size, style.window_style, "REPL-interpreter-"~name), + lines_of_text: [], + history: [], + curr: 0, + coloring: {parents:[nocolor]}, + completion_pos: 0, + }; + m.window.set("title", "Nasal REPL Interpreter"); + #debug.dump(m.window._node); + m.window.del = func() { + delete(me, "del"); + me.del(); # inherited canvas.Window.del(); + m.window = nil; + m.del(); + }; + if (m.window_style != nil) { + m.window.setBool("resize", 1); + m.window.onResize = func() { + call(canvas.Window.onResize, nil, me); + var sz = [nil,nil]; + for (var i=0; i<2; i+=1) + sz[i] = me.get("content-size[" ~ i ~ "]"); + m.scroll.setSize(sz); + }; + } + m.canvas = m.window.createCanvas() + .setColorBackground(m.colors.background); + m.group = m.canvas.createGroup("content"); + m.scroll = canvas.gui.widgets + . ScrollArea.new(m.group, canvas.style, {"size":m.size}) + .move(0, 0); + m.scroll.setColorBackground(m.colors.background); + m.window.addWidget(m.scroll); + m.group = m.scroll.getContent(); + m.create_msg(); + m.text_group = m.group.createChild("group", "text-display"); + m.text = nil; + m.cursor = m.group.createChild("path") + .moveTo(0, -m.padding) + .lineTo(0, -11-m.padding) + .setStrokeLineWidth(2) + .setColor(m.colors.text); + m.repl = REPL.new(placement:m, name:name); + append(m.listeners, setlistener("/devices/status/keyboard/event", func(event) { + if (!event.getNode("pressed").getValue()) + return; + var key = (var keyN = event.getNode("key", 1)).getValue(); + if (key == nil or key == -1) return; + if (m.handle_key(key, event.getNode("modifier").getValues())) + keyN.setValue(-1); # drop key event + })); + m.scroll.update(); # initialize ScrollArea._max_scroll member + m.update(); + append(CanvasPlacement.instances, m); + return m; + }, + del: func() { + if (me.window != nil) + { me.window.del(); me.window = nil } + foreach (var l; me.listeners) + removelistener(l); + setsize(me.listeners, 0); + }, + add_char: func(char) { + me.reset_input_from_history(); + me.input ~= chr(char); + me.text.appendText(chr(char)); + return nil; + }, + add_text: func(text) { + me.reset_input_from_history(); + me.input ~= text; + me.text.appendText(text); + return nil; + }, + remove_char: func() { + me.reset_input_from_history(); + me.input = substr(me.input, 0, size(me.input) - 1); + var t = me.text.get("text"); + if (size(t) <= me.text.stop) return nil; + me.text.setText(substr(t, 0, size(t)-1)); + return t[-1]; + }, + clear_input: func() { + me.reset_input_from_history(); + var ret = me.input; + me.input = ""; + var t = me.text.get("text"); + me.text.setText(substr(t, 0, me.text.stop)); + return ret; + }, + replace_line: func(replacement, replace_input=1) { + if (replace_input) me.input = replacement; + var t = me.text.get("text"); + me.text.setText(substr(t, 0, me.text.stop)~replacement); + return nil; + }, + add_line: func(text) { + me.create_line(); + me.text.appendText(text); + }, + set_line_color: func(color) { + if (me.separate_lines) + # Only change colors if this is its own line + me.text.setColor(color); + }, + clear: func() { + me.text.del(); + foreach (var t; me.lines_of_text) + t.del(); + setsize(me.history, 0); + me.curr = 0; + me.input = ""; + me.text = nil; + setsize(me.lines_of_text, 0); + }, + create_msg: func() { + # Text drawing mode: text and maybe a bounding box + var draw_mode = canvas.Text.TEXT + (me.colors.text_fill != nil ? canvas.Text.FILLEDBOUNDINGBOX : 0); + + me.msg = me.group.createChild("group", "startup-message"); + me.msg.text = me.msg.createChild("text", "help") + .setTranslation(me.padding, me.padding+10) + .setAlignment("left-baseline") + .setFontSize(me.font_size, me.font_aspect_ratio) + .setFont(me.font_file) + .setColor(me.colors.text) + .setDrawMode(draw_mode) + .setMaxWidth(me.window.get("content-size[0]") - me.padding) + .setText(me.translations["help"]); + if (me.colors.text_fill != nil) + me.msg.text.setColorFill(me.colors.text_fill); + me.msg.text.update(); + #debug.dump(me.msg.text.getTransformedBounds()); + me.msg.left_col = me.msg.createChild("text", "keys") + .setTranslation(me.padding, me.msg.text.getTransformedBounds()[3] + 30) + .setAlignment("left-baseline") + .setFontSize(me.font_size, me.font_aspect_ratio) + .setFont(me.font_file) + .setColor(me.colors.text) + .setDrawMode(draw_mode); + if (me.colors.text_fill != nil) + me.msg.left_col.setColorFill(me.colors.text_fill); + me.msg.left_col.update(); + for (var i=0; i" : ""); # FIXME: hack, canvas::Text needs a printing character + # on the first line in order to recognize the newlines ? + if (me.colors.text_fill != nil) + me.text.setColorFill(me.colors.text_fill); + if (me.font_max_width != nil) + if (me.font_max_width < 0) + me.text.setMaxWidth(me.window.get("content-size[0]") - me.font_max_width); + else + me.text.setMaxWidth(me.font_max_width); + + foreach (var t; me.lines_of_text) + me.text.appendText("\n"); + }, + update: func() { + #debug.dump(me.text.getTransformedBounds()); + if (me.state == "startup") return; + me.cursor.setTranslation( + me.text.getTransformedBounds()[2] + 6, + me.text.getTransformedBounds()[3] + 5 + ); + me.scroll.update(); + }, + new_line: func() { + me.create_line(); + me.text.appendText(">>> "); + me.text.stop = size(me.text.get("text")); + me.reset_view(); + }, + continue_line: func() { + me.create_line(); + me.text.appendText("... "); + me.text.stop = size(me.text.get("text")); + me.reset_view(); + }, + reset_input_from_history: func() { + if (me.curr < size(me.history)) { + me.input = me.history[me.curr]; + me.curr = size(me.history); + } + me.reset_view(); + }, + reset_view: func() { + me.group.update(); + me.scroll.moveToLeft().moveToBottom(); + }, + handle_key: func(key, modifiers) { + var modifier_str = ""; + foreach (var m; keys(modifiers)) { + if (modifiers[m]) + modifier_str ~= substr(m,0,1); + } + if (me.state == "startup") { + me.msg.del(); me.msg = nil; + me.new_line(); # initialize a new line + me.text.stop = size(me.text.get("text")); + me.state = "accepting input"; + + } elsif (!contains({"s":,"c":,"":}, modifier_str)) { + return 0; # had extra modifiers, reject this event + + } elsif (modifiers.ctrl) { + if (key == 13) { # ctrl+c + printlog(_REPL_dbg_level, "ctrl+c: "~debug.string(me.input)); + me.reset_input_from_history(); + if( size(me.input) and !clipboard.setText(me.input) ) + print("Failed to write to clipboard"); + } elsif (key == 24) { # ctrl+x + printlog(_REPL_dbg_level, "ctrl+x"); + me.reset_input_from_history(); + if( size(me.input) and !clipboard.setText(me.clear_input()) ) + print("Failed to write to clipboard"); + } elsif (key == 22) { # ctrl+v + var input = clipboard.getText(); + printlog(_REPL_dbg_level, "ctrl+v: "~debug.string(input)); + me.reset_input_from_history(); + for (var i=0; i cancel + printlog(_REPL_dbg_level, "esc"); + me.del(); + return 1; + + } elsif (key == `\t`) { # tab + printlog(_REPL_dbg_level, "tab"); + return 0; + me.reset_input_from_history(); + if (size(text) and text[0] == `/`) { + me.input = me.complete(me.input, modifiers.shift ? -1 : 1); + } + + } elsif (!string.isprint(key)) { + printlog(_REPL_dbg_level, "other key: "~key); + return 0; # pass other funny events + + } else { + printlog(_REPL_dbg_level, "key: "~key~" (`"~chr(key)~"`)"); + me.reset_input_from_history(); + me.add_char(key); + me.completion_pos = -1; + } + + #printlog(_REPL_dbg_level, " -> "~me.input); + + me.update(); + + return 1; # discard key event + }, + get_line: func() { + return me.input; + }, + display_result: func(res=nil) { + if (res == nil) return 1; # don't display NULL results + me.coloring.start(); + var res = call(debug.string, [res], var err=[]); + if (size(err)) { + me.add_line(me.translations["bad-result"] or die("no translation")); + me.set_line_color(me.colors.error); + if (me.font_file == "LiberationFonts/LiberationMono-Bold.ttf") + me.text.setFont("LiberationFonts/LiberationMono-BoldItalic.ttf"); + return 1; + } + me.coloring.end(); + if (size(res) > me.max_output_chars) + res = substr(res, 0, me.max_output_chars-5)~". . ."; + me.add_line(res); + return 1; + }, + handle_runtime_error: func(err) { + debug.printerror(err); + me.add_line("Runtime error: "~err[0]); + me.set_line_color(me.colors.error); + for (var i=1; i", "canvas-default"); + diff --git a/Nasal/debug.nas b/Nasal/debug.nas index 43b502cce..1c7137aa3 100644 --- a/Nasal/debug.nas +++ b/Nasal/debug.nas @@ -218,7 +218,7 @@ var string = func(o) { return _nil("nil"); } elsif (t == "scalar") { - return num(o) == nil ? _dump_string(o) : _num(o); + return num(o) == nil ? _dump_string(o) : _num(o~""); } elsif (t == "vector") { var s = ""; diff --git a/Nasal/prop_key_handler.nas b/Nasal/prop_key_handler.nas index 86a129a00..65866e513 100644 --- a/Nasal/prop_key_handler.nas +++ b/Nasal/prop_key_handler.nas @@ -122,6 +122,8 @@ var handle_key = func(key, shift) { handle_key(`/`, 0); } else { input = text = substr(text, 0, size(text) - 1); + if (text == "") + stop(); # nothing in our field? close the dialog } completion_pos = -1; diff --git a/Translations/en/menu.xml b/Translations/en/menu.xml index 66931f0b3..5e241bad8 100644 --- a/Translations/en/menu.xml +++ b/Translations/en/menu.xml @@ -95,6 +95,7 @@ Reload Materials Reload Scenery Nasal Console + Nasal REPL Interpreter Development Keys Configure Development Extensions Display Tutorial Marker diff --git a/gui/menubar.xml b/gui/menubar.xml index cd9637ea7..d42fde96d 100644 --- a/gui/menubar.xml +++ b/gui/menubar.xml @@ -584,6 +584,14 @@ + + nasal-repl-interpreter + + nasal + + + + development-keys