-- Copyright 2011-16 Paul Kulchenko, ZeroBrane LLC -- authors: Luxinia Dev (Eike Decker & Christoph Kubisch) --------------------------------------------------------- local ide = ide local unpack = table.unpack or unpack local bottomnotebook = ide.frame.bottomnotebook local console = bottomnotebook.shellbox local remotesend local PROMPT_MARKER = StylesGetMarker("prompt") local PROMPT_MARKER_VALUE = 2^PROMPT_MARKER local ERROR_MARKER = StylesGetMarker("error") local OUTPUT_MARKER = StylesGetMarker("output") local MESSAGE_MARKER = StylesGetMarker("message") local config = ide.config.console console:SetFont(ide:CreateFont(config.fontsize or 10, wx.wxFONTFAMILY_MODERN, wx.wxFONTSTYLE_NORMAL, wx.wxFONTWEIGHT_NORMAL, false, config.fontname or "", config.fontencoding or wx.wxFONTENCODING_DEFAULT) ) console:StyleSetFont(wxstc.wxSTC_STYLE_DEFAULT, console:GetFont()) console:StyleClearAll() console:SetTabWidth(ide.config.editor.tabwidth or 2) console:SetIndent(ide.config.editor.tabwidth or 2) console:SetUseTabs(ide.config.editor.usetabs and true or false) console:SetViewWhiteSpace(ide.config.editor.whitespace and true or false) console:SetIndentationGuides(true) console:SetWrapMode(wxstc.wxSTC_WRAP_WORD) console:SetWrapStartIndent(0) console:SetWrapVisualFlagsLocation(wxstc.wxSTC_WRAPVISUALFLAGLOC_END_BY_TEXT) console:SetWrapVisualFlags(wxstc.wxSTC_WRAPVISUALFLAG_END) console:MarkerDefine(StylesGetMarker("prompt")) console:MarkerDefine(StylesGetMarker("error")) console:MarkerDefine(StylesGetMarker("output")) console:MarkerDefine(StylesGetMarker("message")) console:SetReadOnly(false) console:SetupKeywords("lua",nil,ide.config.stylesoutshell) local function getPromptLine() local totalLines = console:GetLineCount() return console:MarkerPrevious(totalLines+1, PROMPT_MARKER_VALUE) end local function getPromptText() local prompt = getPromptLine() return console:GetTextRangeDyn(console:PositionFromLine(prompt), console:GetLength()) end local function setPromptText(text) local length = console:GetLength() console:SetSelectionStart(length - string.len(getPromptText())) console:SetSelectionEnd(length) console:ClearAny() console:AddTextDyn(text) -- refresh the output window to force recalculation of wrapped lines; -- otherwise a wrapped part of the last line may not be visible. console:Update(); console:Refresh() console:GotoPos(console:GetLength()) end local function positionInLine(line) return console:GetCurrentPos() - console:PositionFromLine(line) end local function caretOnPromptLine(disallowLeftmost, line) local promptLine = getPromptLine() local currentLine = line or console:GetCurrentLine() local boundary = disallowLeftmost and 0 or -1 return (currentLine > promptLine or currentLine == promptLine and positionInLine(promptLine) > boundary) end local function chomp(line) return (line:gsub("%s+$", "")) end local function getInput(line) local nextMarker = line local count = console:GetLineCount() repeat -- check until we find at least some marker nextMarker = nextMarker+1 until console:MarkerGet(nextMarker) > 0 or nextMarker > count-1 return chomp(console:GetTextRangeDyn( console:PositionFromLine(line), console:PositionFromLine(nextMarker))) end function ConsoleSelectCommand(point) local cpos = console:ScreenToClient(point or wx.wxGetMousePosition()) local position = console:PositionFromPoint(cpos) if position == wxstc.wxSTC_INVALID_POSITION then return end local promptline = console:MarkerPrevious(console:LineFromPosition(position), PROMPT_MARKER_VALUE) if promptline == wxstc.wxSTC_INVALID_POSITION then return end local nextline = console:MarkerNext(promptline+1, ide.ANYMARKERMASK) local epos = nextline ~= wxstc.wxSTC_INVALID_POSITION and console:PositionFromLine(nextline) or console:GetLength() console:SetSelection(console:PositionFromLine(promptline), epos) return true end local currentHistory local lastCommand = "" local function getNextHistoryLine(forward, promptText) local count = console:GetLineCount() if currentHistory == nil then currentHistory = count end if forward then currentHistory = console:MarkerNext(currentHistory+1, PROMPT_MARKER_VALUE) if currentHistory == wx.wxNOT_FOUND then currentHistory = count return "" end else currentHistory = console:MarkerPrevious(currentHistory-1, PROMPT_MARKER_VALUE) if currentHistory == wx.wxNOT_FOUND then return lastCommand end end -- need to skip the current prompt line -- or skip repeated commands if currentHistory == getPromptLine() or getInput(currentHistory) == promptText then return getNextHistoryLine(forward, promptText) end return getInput(currentHistory) end local function getNextHistoryMatch(promptText) local count = console:GetLineCount() if currentHistory == nil then currentHistory = count end local current = currentHistory while true do currentHistory = console:MarkerPrevious(currentHistory-1, PROMPT_MARKER_VALUE) if currentHistory == wx.wxNOT_FOUND then -- restart search from the last item currentHistory = count elseif currentHistory ~= getPromptLine() then -- skip current prompt local input = getInput(currentHistory) if input:find(promptText, 1, true) == 1 then return input end end -- couldn't find anything and made a loop; get out if currentHistory == current then return end end assert(false, "getNextHistoryMatch coudn't find a proper match") end local function concat(sep, ...) local text = "" for i=1, select('#',...) do text = text .. (i > 1 and sep or "") .. tostring(select(i,...)) end -- split the text into smaller chunks as one large line -- is difficult to handle for the editor local prev, maxlength = 0, ide.config.debugger.maxdatalength if #text > maxlength and not text:find("\n.") then text = text:gsub("()(%s+)", function(p, s) if p-prev >= maxlength then prev = p return "\n" else return s end end) end return text end local partial = false local function shellPrint(marker, text, newline) if not text or text == "" then return end -- return if nothing to print if newline then text = text:gsub("\n+$", "").."\n" end local isPrompt = marker and (getPromptLine() ~= wx.wxNOT_FOUND) local lines = console:GetLineCount() local promptLine = isPrompt and getPromptLine() or nil local insertLineAt = isPrompt and not partial and getPromptLine() or console:GetLineCount()-1 local insertAt = isPrompt and not partial and console:PositionFromLine(getPromptLine()) or console:GetLength() console:InsertTextDyn(insertAt, console.useraw and text or FixUTF8(text, function (s) return '\\'..string.byte(s) end)) local linesAdded = console:GetLineCount() - lines partial = text:find("\n$") == nil if marker then if promptLine then console:MarkerDelete(promptLine, PROMPT_MARKER) end for line = insertLineAt, insertLineAt + linesAdded - 1 do console:MarkerAdd(line, marker) end if promptLine then console:MarkerAdd(promptLine+linesAdded, PROMPT_MARKER) end end console:EmptyUndoBuffer() -- don't allow the user to undo shell text console:GotoPos(console:GetLength()) console:EnsureVisibleEnforcePolicy(console:GetLineCount()-1) end displayShellDirect = function (...) shellPrint(nil, concat("\t", ...), true) end DisplayShell = function (...) shellPrint(OUTPUT_MARKER, concat("\t", ...), true) end DisplayShellErr = function (...) shellPrint(ERROR_MARKER, concat("\t", ...), true) end DisplayShellMsg = function (...) shellPrint(MESSAGE_MARKER, concat("\t", ...), true) end -- don't print anything; just mark the line with a prompt mark DisplayShellPrompt = function (...) console:MarkerAdd(console:GetLineCount()-1, PROMPT_MARKER) end function console:Print(...) return DisplayShell(...) end function console:Write(...) return shellPrint(OUTPUT_MARKER, concat("", ...), false) end function console:Error(...) return DisplayShellErr(...) end local function filterTraceError(err, addedret) local err = err:match("(.-:%d+:.-)\n[^\n]*\n[^\n]*\n[^\n]*src/editor/shellbox.lua:.*in function 'executeShellCode'") or err err = err:gsub("stack traceback:.-\n[^\n]+\n?","") if addedret then err = err:gsub('^%[string "return ', '[string "') end err = err:match("(.*)\n[^\n]*%(tail call%): %?$") or err return err end local function createenv() local env = {} setmetatable(env,{__index = _G}) local function luafilename(level) level = level and level + 1 or 2 local src while (true) do src = debug.getinfo(level) if (src == nil) then return nil,level end if (string.byte(src.source) == string.byte("@")) then return string.sub(src.source,2),level end level = level + 1 end end local function luafilepath(level) local src,level = luafilename(level) if (src == nil) then return src,level end src = string.gsub(src,"[\\/][^\\//]*$","") return src,level end local function relativeFilename(file) assert(type(file)=='string',"String as filename expected") local name = file local level = 3 while (name) do if (wx.wxFileName(name):FileExists()) then return name end name,level = luafilepath(level) if (name == nil) then break end name = name .. "/" .. file end return file end local function relativeFilepath(file) local name = luafilepath(3) return (file and name) and name.."/"..file or file or name end local _loadfile = loadfile local function loadfile(file) assert(type(file)=='string',"String as filename expected") local name = relativeFilename(file) return _loadfile(name) end local function dofile(file, ...) assert(type(file) == 'string',"String as filename expected") local fn,err = loadfile(file) local args = {...} if not fn then DisplayShellErr(err) else setfenv(fn,env) return fn(unpack(args)) end end local os = { exit = function() ide.frame:AddPendingEvent(wx.wxCommandEvent(wx.wxEVT_COMMAND_MENU_SELECTED, ID.EXIT)) end, } env.os = setmetatable(os, {__index = _G.os}) env.io = setmetatable({write = function(...) console:Write(...) end}, {__index = _G.io}) env.print = function(...) console:Print(...) end env.dofile = dofile env.loadfile = loadfile env.RELFILE = relativeFilename env.RELPATH = relativeFilepath return env end local env = createenv() function ShellSetAlias(alias, table) local value = env[alias] env[alias] = table return value end local function packResults(status, ...) return status, {...} end local function executeShellCode(tx) if tx == nil or tx == '' then return end local forcelocalprefix = '^!' local forcelocal = tx:find(forcelocalprefix) tx = tx:gsub(forcelocalprefix, '') DisplayShellPrompt('') -- try to compile as statement local loadstring = loadstring or load local _, err = loadstring(tx) local isstatement = not err if remotesend and not forcelocal then remotesend(tx, isstatement); return end local addedret, forceexpression = true, tx:match("^%s*=%s*") tx = tx:gsub("^%s*=%s*","") local fn fn, err = loadstring("return "..tx) if not forceexpression and err then fn, err = loadstring(tx) addedret = false end if fn == nil and err then DisplayShellErr(filterTraceError(err, addedret)) elseif fn then setfenv(fn,env) -- set the project dir as the current dir to allow "require" calls -- to work from shell local projectDir, cwd = ide:GetProject(), nil if projectDir and #projectDir > 0 then cwd = wx.wxFileName.GetCwd() wx.wxFileName.SetCwd(projectDir) end local ok, res = packResults(xpcall(fn, function(err) DisplayShellErr(filterTraceError(debug.traceback(err), addedret)) end)) -- restore the current dir if cwd then wx.wxFileName.SetCwd(cwd) end if ok and (addedret or #res > 0) then if addedret then local mobdebug = require "mobdebug" for i,v in pairs(res) do -- stringify each of the returned values res[i] = (forceexpression and i > 1 and '\n' or '') .. mobdebug.line(v, {nocode = true, comment = 1, -- if '=' is used, then use multi-line serialized output indent = forceexpression and ' ' or nil}) end -- add nil only if we are forced (using =) or if this is not a statement -- this is needed to print 'nil' when asked for 'foo', -- and don't print it when asked for 'print(1)' if #res == 0 and (forceexpression or not isstatement) then res = {'nil'} end end DisplayShell(unpack(res)) end end end function console:GetRemote() return remotesend end function console:SetRemote(client) remotesend = client local index = bottomnotebook:GetPageIndex(console) if index then bottomnotebook:SetPageText(index, client and TR("Remote console") or TR("Local console")) end end function ShellExecuteFile(wfilename) if (not wfilename) then return end local cmd = 'dofile([['..wfilename:GetFullPath()..']])' ShellExecuteCode(cmd) end ShellExecuteInline = executeShellCode function ShellExecuteCode(code) local index = bottomnotebook:GetPageIndex(bottomnotebook.shellbox) if ide.config.activateoutput and bottomnotebook:GetSelection() ~= index then bottomnotebook:SetSelection(index) end displayShellDirect(code) executeShellCode(code) end local function displayShellIntro() DisplayShellMsg(TR("Welcome to the interactive Lua interpreter.").." " ..TR("Enter Lua code and press Enter to run it.").." " ..TR("Use Shift-Enter for multiline code.").."\n" ..TR("Use 'clear' to clear the shell output and the history.").." " ..TR("Use 'reset' to clear the environment.").."\n" ..TR("Prepend '=' to show complex values on multiple lines.").." " ..TR("Prepend '!' to force local execution.")) DisplayShellPrompt('') end console:Connect(wx.wxEVT_KEY_DOWN, function (event) -- this loop is only needed to allow to get to the end of function easily -- "return" aborts the processing and ignores the key -- "break" aborts the processing and processes the key normally while true do local key = event:GetKeyCode() local modifiers = event:GetModifiers() if key == wx.WXK_UP or key == wx.WXK_NUMPAD_UP then -- if we are below the prompt line, then allow to go up -- through multiline entry if console:GetCurrentLine() > getPromptLine() then break end -- if we are not on the caret line, or are on wrapped caret line, move normally if not caretOnPromptLine() or console:GetLineWrapped(console:GetCurrentPos(), -1) then break end -- only change prompt if no modifiers are used (to allow for selection movement) if modifiers == wx.wxMOD_NONE then local promptText = getPromptText() setPromptText(getNextHistoryLine(false, promptText)) -- move to the beginning of the updated prompt console:GotoPos(console:PositionFromLine(getPromptLine())) end return elseif key == wx.WXK_DOWN or key == wx.WXK_NUMPAD_DOWN then -- if we are above the last line, then allow to go down -- through multiline entry local totalLines = console:GetLineCount()-1 if console:GetCurrentLine() < totalLines then break end -- if we are not on the caret line, or are on wrapped caret line, move normally if not caretOnPromptLine() or console:GetLineWrapped(console:GetCurrentPos(), 1) then break end -- only change prompt if no modifiers are used (to allow for selection movement) if modifiers == wx.wxMOD_NONE then local promptText = getPromptText() setPromptText(getNextHistoryLine(true, promptText)) -- staying at the end of the updated prompt end return elseif key == wx.WXK_TAB then -- if we are above the prompt line, then don't move local promptline = getPromptLine() if console:GetCurrentLine() < promptline then return end local promptText = getPromptText() -- save the position in the prompt text to restore local pos = console:GetCurrentPos() local text = promptText:sub(1, positionInLine(promptline)) if #text == 0 then return end -- find the next match and set the prompt text local match = getNextHistoryMatch(text) if match then setPromptText(match) -- restore the position to make it easier to find the next match console:GotoPos(pos) end return elseif key == wx.WXK_ESCAPE then setPromptText("") return elseif key == wx.WXK_BACK or key == wx.WXK_LEFT or key == wx.WXK_NUMPAD_LEFT then if (key == wx.WXK_BACK or console:LineFromPosition(console:GetCurrentPos()) >= getPromptLine()) and not caretOnPromptLine(true) then return end elseif key == wx.WXK_DELETE or key == wx.WXK_NUMPAD_DELETE then if not caretOnPromptLine() or console:LineFromPosition(console:GetSelectionStart()) < getPromptLine() then return end elseif key == wx.WXK_PAGEUP or key == wx.WXK_NUMPAD_PAGEUP or key == wx.WXK_PAGEDOWN or key == wx.WXK_NUMPAD_PAGEDOWN or key == wx.WXK_END or key == wx.WXK_NUMPAD_END or key == wx.WXK_HOME or key == wx.WXK_NUMPAD_HOME -- `key == wx.WXK_LEFT or key == wx.WXK_NUMPAD_LEFT` are handled separately or key == wx.WXK_RIGHT or key == wx.WXK_NUMPAD_RIGHT or key == wx.WXK_SHIFT or key == wx.WXK_CONTROL or key == wx.WXK_ALT then break elseif key == wx.WXK_RETURN or key == wx.WXK_NUMPAD_ENTER then if not caretOnPromptLine() or console:LineFromPosition(console:GetSelectionStart()) < getPromptLine() then return end -- allow multiline entry for shift+enter if caretOnPromptLine(true) and event:ShiftDown() then break end local promptText = getPromptText() if #promptText == 0 then return end -- nothing to execute, exit if promptText == 'clear' then console:Erase() elseif promptText == 'reset' then console:Reset() setPromptText("") else displayShellDirect('\n') executeShellCode(promptText) end currentHistory = getPromptLine() -- reset history return -- don't need to do anything else with return elseif modifiers == wx.wxMOD_NONE or console:GetSelectedText() == "" then -- move cursor to end if not already there if not caretOnPromptLine() then console:GotoPos(console:GetLength()) console:SetReadOnly(false) -- allow the current character to appear at the new location -- check if the selection starts before the prompt line and reset it elseif console:LineFromPosition(console:GetSelectionStart()) < getPromptLine() then console:GotoPos(console:GetLength()) console:SetSelection(console:GetSelectionEnd()+1,console:GetSelectionEnd()) end end break end event:Skip() end) local function inputEditable(line) return caretOnPromptLine(false, line) and not (console:LineFromPosition(console:GetSelectionStart()) < getPromptLine()) end -- Scintilla 3.2.1+ changed the way markers move when the text is updated -- ticket: http://sourceforge.net/p/scintilla/bugs/939/ -- discussion: https://groups.google.com/forum/?hl=en&fromgroups#!topic/scintilla-interest/4giFiKG4VXo if ide.wxver >= "2.9.5" then -- this is a workaround that stores a position of the last prompt marker -- before insert and restores the same position after as the marker -- could have moved if the text is added at the beginning of the line. local promptAt console:Connect(wxstc.wxEVT_STC_MODIFIED, function (event) local evtype = event:GetModificationType() if bit.band(evtype, wxstc.wxSTC_MOD_BEFOREINSERT) ~= 0 then local promptLine = getPromptLine() if promptLine and event:GetPosition() == console:PositionFromLine(promptLine) then promptAt = promptLine end end if bit.band(evtype, wxstc.wxSTC_MOD_INSERTTEXT) ~= 0 then local promptLine = getPromptLine() if promptLine and promptAt then console:MarkerDelete(promptLine, PROMPT_MARKER) console:MarkerAdd(promptAt, PROMPT_MARKER) promptAt = nil end end end) end console:Connect(wxstc.wxEVT_STC_UPDATEUI, function (event) console:SetReadOnly(not inputEditable()) end) -- only allow copy/move text by dropping to the input line console:Connect(wxstc.wxEVT_STC_DO_DROP, function (event) if not inputEditable(console:LineFromPosition(event:GetPosition())) then event:SetDragResult(wx.wxDragNone) end end) if config.nomousezoom then -- disable zoom using mouse wheel as it triggers zooming when scrolling -- on OSX with kinetic scroll and then pressing CMD. console:Connect(wx.wxEVT_MOUSEWHEEL, function (event) if wx.wxGetKeyState(wx.WXK_CONTROL) then return end event:Skip() end) end displayShellIntro() function console:Erase() -- save the last command to keep when the history is cleared currentHistory = getPromptLine() lastCommand = getNextHistoryLine(false, "") -- allow writing as the editor may be read-only depending on current cursor position self:SetReadOnly(false) self:ClearAll() displayShellIntro() end function console:Reset() env = createenv() -- recreate the environment to "forget" all changes in it end