diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1998c29 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.cache \ No newline at end of file diff --git a/bin/7zip.cmd b/bin/7zip.cmd new file mode 100644 index 0000000..4a3c7b9 --- /dev/null +++ b/bin/7zip.cmd @@ -0,0 +1,2 @@ +@echo off +call %VID_HOME%\tools\7z.bat %* \ No newline at end of file diff --git a/bin/cmake.cmd b/bin/cmake.cmd new file mode 100644 index 0000000..284daa0 --- /dev/null +++ b/bin/cmake.cmd @@ -0,0 +1,2 @@ +@echo off +call %VID_HOME%\tools\cmakew.bat %* \ No newline at end of file diff --git a/bin/edit.cmd b/bin/edit.cmd new file mode 100644 index 0000000..88740a3 --- /dev/null +++ b/bin/edit.cmd @@ -0,0 +1,5 @@ +@echo off +setlocal +for /D %%A in (. %1) do set open=%%~dpnA +echo %open% +start "" %VID_HOME%\tools\lite\lite.exe %open% diff --git a/bin/g++.cmd b/bin/g++.cmd new file mode 100644 index 0000000..0e2b0ae --- /dev/null +++ b/bin/g++.cmd @@ -0,0 +1,2 @@ +@echo off +call %VID_HOME%\tools\6.0g-mingw64.bat g++ %* \ No newline at end of file diff --git a/bin/gcc.cmd b/bin/gcc.cmd new file mode 100644 index 0000000..a0bac38 --- /dev/null +++ b/bin/gcc.cmd @@ -0,0 +1,2 @@ +@echo off +call %VID_HOME%\tools\6.0g-mingw64.bat gcc %* \ No newline at end of file diff --git a/bin/make.cmd b/bin/make.cmd new file mode 100644 index 0000000..74d74f2 --- /dev/null +++ b/bin/make.cmd @@ -0,0 +1,2 @@ +@echo off +call %VID_HOME%\tools\6.0g-mingw64.bat mingw32-make %* \ No newline at end of file diff --git a/bin/vid-init.cmd b/bin/vid-init.cmd new file mode 100644 index 0000000..4693dc2 --- /dev/null +++ b/bin/vid-init.cmd @@ -0,0 +1,7 @@ +@echo off +set fn=%VID_HOME%\templates\%1.7z +if exist %fn% ( + call %VID_HOME%\tools\7z.bat x -o. -y %fn% +) else ( + echo Template %1 not found +) \ No newline at end of file diff --git a/start.cmd b/start.cmd new file mode 100644 index 0000000..538e240 --- /dev/null +++ b/start.cmd @@ -0,0 +1,4 @@ +@echo off +set VID_HOME=%~dp0 +set PATH=%~dp0\bin;%PATH% +cmd /k \ No newline at end of file diff --git a/templates/C-cmake.7z b/templates/C-cmake.7z new file mode 100644 index 0000000..116bf95 Binary files /dev/null and b/templates/C-cmake.7z differ diff --git a/tools/6.0g-mingw64.bat b/tools/6.0g-mingw64.bat index 9bb3560..c1e44cd 100644 --- a/tools/6.0g-mingw64.bat +++ b/tools/6.0g-mingw64.bat @@ -6,7 +6,7 @@ setlocal enableDelayedExpansion if "%OS%"=="Windows_NT" setlocal @rem get script directory name -set DIRNAME=%~dp0 +set DIRNAME=%~dp0\..\.cache if "%DIRNAME%" == "" set DIRNAME=. @rem strip trailing backslash from DIRNAME path to make it easier to work with diff --git a/tools/7z.bat b/tools/7z.bat new file mode 100644 index 0000000..bd2e9a0 --- /dev/null +++ b/tools/7z.bat @@ -0,0 +1,66 @@ +@echo off +setlocal ENABLEEXTENSIONS +setlocal enableDelayedExpansion + +@rem set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem get script directory name +set DIRNAME=%~dp0\..\.cache +if "%DIRNAME%" == "" set DIRNAME=. + +@rem strip trailing backslash from DIRNAME path to make it easier to work with +IF %DIRNAME:~-1%==\ SET DIRNAME=%DIRNAME:~0,-1% + +@rem get OS bitness +reg Query "HKLM\Hardware\Description\System\CentralProcessor\0" | find /i "x86" > NUL && set OS_BITNESS=32BIT || set OS_BITNESS=64BIT + +set UNZIP_EXE=%DIRNAME%\7zip\7z.exe + +@rem require 7zip to unzip the cmake dist because windows sucks +if not exist %UNZIP_EXE% ( + echo Downloading 7zip... + + if %OS_BITNESS%==64BIT ( + call :downloadFile https://www.7-zip.org/a/7z1801-x64.exe %TEMP%\7zInstall.exe + ) else ( + call :downloadFile https://www.7-zip.org/a/7z1801.exe %TEMP%\7zInstall.exe + ) + + echo Installing 7zip + + @rem don't forget the trailing backslash here because 7zip is too stupid to deal with paths and just removes the last char + %TEMP%\7zInstall.exe /S /D=%DIRNAME%\7zip\ + del %TEMP%\7zInstall.exe +) +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto runCmake + +set CMD_LINE_ARGS=%* +goto runCmake + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:runCmake + +%UNZIP_EXE% %CMD_LINE_ARGS% +goto end + + +@rem args: fileUrl, filePath +:downloadFile +powershell -Command "(New-Object Net.WebClient).DownloadFile('%~1', '%~2')" +EXIT /B 0 + +:end +if "%OS%"=="Windows_NT" endlocal \ No newline at end of file diff --git a/tools/cmakew.bat b/tools/cmakew.bat index 9150e39..c6beb7e 100644 --- a/tools/cmakew.bat +++ b/tools/cmakew.bat @@ -11,17 +11,16 @@ set "CMAKE_VERSION=3.13.0" for /f "tokens=1,2,3 delims=." %%a in ("%CMAKE_VERSION%") do set CMAKE_VERSION_MAJOR=%%a&set CMAKE_VERSION_MINOR=%%b&set CMAKE_VERSION_PATCH=%%c @rem get script directory name -set DIRNAME=%~dp0 +set DIRNAME=%~dp0\..\.cache if "%DIRNAME%" == "" set DIRNAME=. @rem strip trailing backslash from DIRNAME path to make it easier to work with IF %DIRNAME:~-1%==\ SET DIRNAME=%DIRNAME:~0,-1% @rem get OS bitness -echo (ignore reg error, I don't know where it comes from) reg Query "HKLM\Hardware\Description\System\CentralProcessor\0" | find /i "x86" > NUL && set OS_BITNESS=32BIT || set OS_BITNESS=64BIT -set UNZIP_EXE=%DIRNAME%\.cmakew\7zip\7z.exe +set UNZIP_EXE=%DIRNAME%\7zip\7z.exe @rem require 7zip to unzip the cmake dist because windows sucks if not exist %UNZIP_EXE% ( @@ -36,11 +35,11 @@ if not exist %UNZIP_EXE% ( echo Installing 7zip @rem don't forget the trailing backslash here because 7zip is too stupid to deal with paths and just removes the last char - %TEMP%\7zInstall.exe /S /D=%DIRNAME%\.cmakew\7zip\ + %TEMP%\7zInstall.exe /S /D=%DIRNAME%\7zip\ del %TEMP%\7zInstall.exe ) -set CMAKE_DIR=%DIRNAME%\.cmakew\cmake-%CMAKE_VERSION% +set CMAKE_DIR=%DIRNAME%\cmake-%CMAKE_VERSION% set CMAKE_ZIP=%TEMP%\cmake-%CMAKE_VERSION%.zip set CMAKE_EXE=%CMAKE_DIR%\bin\cmake.exe @@ -56,13 +55,13 @@ if not exist %CMAKE_EXE% ( echo Installing cmake - %UNZIP_EXE% x %CMAKE_ZIP% -o%DIRNAME%\.cmakew -y + %UNZIP_EXE% x %CMAKE_ZIP% -o%DIRNAME% -y del %CMAKE_ZIP% if %OS_BITNESS%==64BIT ( - ren %DIRNAME%\.cmakew\cmake-%CMAKE_VERSION%-win64-x64 cmake-%CMAKE_VERSION% + ren %DIRNAME%\cmake-%CMAKE_VERSION%-win64-x64 cmake-%CMAKE_VERSION% ) else ( - ren %DIRNAME%\.cmakew\cmake-%CMAKE_VERSION%-win32-x86 cmake-%CMAKE_VERSION% + ren %DIRNAME%\cmake-%CMAKE_VERSION%-win32-x86 cmake-%CMAKE_VERSION% ) ) diff --git a/tools/lite/SDL2.dll b/tools/lite/SDL2.dll new file mode 100644 index 0000000..7c546bc Binary files /dev/null and b/tools/lite/SDL2.dll differ diff --git a/tools/lite/data/core/command.lua b/tools/lite/data/core/command.lua new file mode 100644 index 0000000..5164a13 --- /dev/null +++ b/tools/lite/data/core/command.lua @@ -0,0 +1,69 @@ +local core = require "core" +local command = {} + +command.map = {} + +local always_true = function() return true end + + +function command.add(predicate, map) + predicate = predicate or always_true + if type(predicate) == "string" then + predicate = require(predicate) + end + if type(predicate) == "table" then + local class = predicate + predicate = function() return core.active_view:is(class) end + end + for name, fn in pairs(map) do + assert(not command.map[name], "command already exists: " .. name) + command.map[name] = { predicate = predicate, perform = fn } + end +end + + +local function capitalize_first(str) + return str:sub(1, 1):upper() .. str:sub(2) +end + +function command.prettify_name(name) + return name:gsub(":", ": "):gsub("-", " "):gsub("%S+", capitalize_first) +end + + +function command.get_all_valid() + local res = {} + for name, cmd in pairs(command.map) do + if cmd.predicate() then + table.insert(res, name) + end + end + return res +end + + +local function perform(name) + local cmd = command.map[name] + if cmd and cmd.predicate() then + cmd.perform() + return true + end + return false +end + + +function command.perform(...) + local ok, res = core.try(perform, ...) + return not ok or res +end + + +function command.add_defaults() + local reg = { "core", "root", "command", "doc", "findreplace" } + for _, name in ipairs(reg) do + require("core.commands." .. name) + end +end + + +return command diff --git a/tools/lite/data/core/commands/command.lua b/tools/lite/data/core/commands/command.lua new file mode 100644 index 0000000..f0b8007 --- /dev/null +++ b/tools/lite/data/core/commands/command.lua @@ -0,0 +1,30 @@ +local core = require "core" +local command = require "core.command" +local CommandView = require "core.commandview" + +local function has_commandview() + return core.active_view:is(CommandView) +end + + +command.add(has_commandview, { + ["command:submit"] = function() + core.active_view:submit() + end, + + ["command:complete"] = function() + core.active_view:complete() + end, + + ["command:escape"] = function() + core.active_view:exit() + end, + + ["command:select-previous"] = function() + core.active_view:move_suggestion_idx(1) + end, + + ["command:select-next"] = function() + core.active_view:move_suggestion_idx(-1) + end, +}) diff --git a/tools/lite/data/core/commands/core.lua b/tools/lite/data/core/commands/core.lua new file mode 100644 index 0000000..dbe08a4 --- /dev/null +++ b/tools/lite/data/core/commands/core.lua @@ -0,0 +1,101 @@ +local core = require "core" +local common = require "core.common" +local command = require "core.command" +local keymap = require "core.keymap" +local LogView = require "core.logview" + + +local fullscreen = false + +command.add(nil, { + ["core:quit"] = function() + core.quit() + end, + + ["core:force-quit"] = function() + core.quit(true) + end, + + ["core:toggle-fullscreen"] = function() + fullscreen = not fullscreen + system.set_window_mode(fullscreen and "fullscreen" or "normal") + end, + + ["core:reload-module"] = function() + core.command_view:enter("Reload Module", function(text, item) + local text = item and item.text or text + core.reload_module(text) + core.log("Reloaded module %q", text) + end, function(text) + local items = {} + for name in pairs(package.loaded) do + table.insert(items, name) + end + return common.fuzzy_match(items, text) + end) + end, + + ["core:find-command"] = function() + local commands = command.get_all_valid() + core.command_view:enter("Do Command", function(text, item) + if item then + command.perform(item.command) + end + end, function(text) + local res = common.fuzzy_match(commands, text) + for i, name in ipairs(res) do + res[i] = { + text = command.prettify_name(name), + info = keymap.get_binding(name), + command = name, + } + end + return res + end) + end, + + ["core:find-file"] = function() + core.command_view:enter("Open File From Project", function(text, item) + text = item and item.text or text + core.root_view:open_doc(core.open_doc(text)) + end, function(text) + local files = {} + for _, item in pairs(core.project_files) do + if item.type == "file" then + table.insert(files, item.filename) + end + end + return common.fuzzy_match(files, text) + end) + end, + + ["core:new-doc"] = function() + core.root_view:open_doc(core.open_doc()) + end, + + ["core:open-file"] = function() + core.command_view:enter("Open File", function(text) + core.root_view:open_doc(core.open_doc(text)) + end, common.path_suggest) + end, + + ["core:open-log"] = function() + local node = core.root_view:get_active_node() + node:add_view(LogView()) + end, + + ["core:open-user-module"] = function() + core.root_view:open_doc(core.open_doc(EXEDIR .. "/data/user/init.lua")) + end, + + ["core:open-project-module"] = function() + local filename = ".lite_project.lua" + if system.get_file_info(filename) then + core.root_view:open_doc(core.open_doc(filename)) + else + local doc = core.open_doc() + core.root_view:open_doc(doc) + doc:save(filename) + end + end, +}) diff --git a/tools/lite/data/core/commands/doc.lua b/tools/lite/data/core/commands/doc.lua new file mode 100644 index 0000000..1afd546 --- /dev/null +++ b/tools/lite/data/core/commands/doc.lua @@ -0,0 +1,363 @@ +local core = require "core" +local command = require "core.command" +local common = require "core.common" +local config = require "core.config" +local translate = require "core.doc.translate" +local DocView = require "core.docview" + + +local function dv() + return core.active_view +end + + +local function doc() + return core.active_view.doc +end + + +local function get_indent_string() + if config.tab_type == "hard" then + return "\t" + end + return string.rep(" ", config.indent_size) +end + + +local function insert_at_start_of_selected_lines(text, skip_empty) + local line1, col1, line2, col2, swap = doc():get_selection(true) + for line = line1, line2 do + local line_text = doc().lines[line] + if (not skip_empty or line_text:find("%S")) then + doc():insert(line, 1, text) + end + end + doc():set_selection(line1, col1 + #text, line2, col2 + #text, swap) +end + + +local function remove_from_start_of_selected_lines(text, skip_empty) + local line1, col1, line2, col2, swap = doc():get_selection(true) + for line = line1, line2 do + local line_text = doc().lines[line] + if line_text:sub(1, #text) == text + and (not skip_empty or line_text:find("%S")) + then + doc():remove(line, 1, line, #text + 1) + end + end + doc():set_selection(line1, col1 - #text, line2, col2 - #text, swap) +end + + +local function append_line_if_last_line(line) + if line >= #doc().lines then + doc():insert(line, math.huge, "\n") + end +end + + +local function save(filename) + doc():save(filename) + core.log("Saved \"%s\"", doc().filename) +end + + +local commands = { + ["doc:undo"] = function() + doc():undo() + end, + + ["doc:redo"] = function() + doc():redo() + end, + + ["doc:cut"] = function() + if doc():has_selection() then + local text = doc():get_text(doc():get_selection()) + system.set_clipboard(text) + doc():delete_to(0) + end + end, + + ["doc:copy"] = function() + if doc():has_selection() then + local text = doc():get_text(doc():get_selection()) + system.set_clipboard(text) + end + end, + + ["doc:paste"] = function() + doc():text_input(system.get_clipboard():gsub("\r", "")) + end, + + ["doc:newline"] = function() + local line, col = doc():get_selection() + local indent = doc().lines[line]:match("^[\t ]*") + if col <= #indent then + indent = indent:sub(#indent + 2 - col) + end + doc():text_input("\n" .. indent) + end, + + ["doc:newline-below"] = function() + local line = doc():get_selection() + local indent = doc().lines[line]:match("^[\t ]*") + doc():insert(line, math.huge, "\n" .. indent) + doc():set_selection(line + 1, math.huge) + end, + + ["doc:newline-above"] = function() + local line = doc():get_selection() + local indent = doc().lines[line]:match("^[\t ]*") + doc():insert(line, 1, indent .. "\n") + doc():set_selection(line, math.huge) + end, + + ["doc:delete"] = function() + local line, col = doc():get_selection() + if not doc():has_selection() and doc().lines[line]:find("^%s*$", col) then + doc():remove(line, col, line, math.huge) + end + doc():delete_to(translate.next_char) + end, + + ["doc:backspace"] = function() + local line, col = doc():get_selection() + if not doc():has_selection() then + local text = doc():get_text(line, 1, line, col) + if #text >= config.indent_size and text:find("^ *$") then + doc():delete_to(0, -config.indent_size) + return + end + end + doc():delete_to(translate.previous_char) + end, + + ["doc:select-all"] = function() + doc():set_selection(1, 1, math.huge, math.huge) + end, + + ["doc:select-none"] = function() + local line, col = doc():get_selection() + doc():set_selection(line, col) + end, + + ["doc:select-lines"] = function() + local line1, _, line2, _, swap = doc():get_selection(true) + append_line_if_last_line(line2) + doc():set_selection(line1, 1, line2 + 1, 1, swap) + end, + + ["doc:select-word"] = function() + local line1, col1 = doc():get_selection(true) + local line1, col1 = translate.start_of_word(doc(), line1, col1) + local line2, col2 = translate.end_of_word(doc(), line1, col1) + doc():set_selection(line2, col2, line1, col1) + end, + + ["doc:join-lines"] = function() + local line1, _, line2 = doc():get_selection(true) + if line1 == line2 then line2 = line2 + 1 end + local text = doc():get_text(line1, 1, line2, math.huge) + text = text:gsub("\n[\t ]*", " ") + doc():insert(line1, 1, text) + doc():remove(line1, #text + 1, line2, math.huge) + if doc():has_selection() then + doc():set_selection(line1, math.huge) + end + end, + + ["doc:indent"] = function() + local text = get_indent_string() + if doc():has_selection() then + insert_at_start_of_selected_lines(text) + else + doc():text_input(text) + end + end, + + ["doc:unindent"] = function() + local text = get_indent_string() + remove_from_start_of_selected_lines(text) + end, + + ["doc:duplicate-lines"] = function() + local line1, col1, line2, col2, swap = doc():get_selection(true) + append_line_if_last_line(line2) + local text = doc():get_text(line1, 1, line2 + 1, 1) + doc():insert(line2 + 1, 1, text) + local n = line2 - line1 + 1 + doc():set_selection(line1 + n, col1, line2 + n, col2, swap) + end, + + ["doc:delete-lines"] = function() + local line1, col1, line2 = doc():get_selection(true) + append_line_if_last_line(line2) + doc():remove(line1, 1, line2 + 1, 1) + doc():set_selection(line1, col1) + end, + + ["doc:move-lines-up"] = function() + local line1, col1, line2, col2, swap = doc():get_selection(true) + append_line_if_last_line(line2) + if line1 > 1 then + local text = doc().lines[line1 - 1] + doc():insert(line2 + 1, 1, text) + doc():remove(line1 - 1, 1, line1, 1) + doc():set_selection(line1 - 1, col1, line2 - 1, col2, swap) + end + end, + + ["doc:move-lines-down"] = function() + local line1, col1, line2, col2, swap = doc():get_selection(true) + append_line_if_last_line(line2 + 1) + if line2 < #doc().lines then + local text = doc().lines[line2 + 1] + doc():remove(line2 + 1, 1, line2 + 2, 1) + doc():insert(line1, 1, text) + doc():set_selection(line1 + 1, col1, line2 + 1, col2, swap) + end + end, + + ["doc:toggle-line-comments"] = function() + local comment = doc().syntax.comment + if not comment then return end + local comment_text = comment .. " " + local line1, _, line2 = doc():get_selection(true) + local uncomment = true + for line = line1, line2 do + local text = doc().lines[line] + if text:find("%S") and text:find(comment_text, 1, true) ~= 1 then + uncomment = false + end + end + if uncomment then + remove_from_start_of_selected_lines(comment_text, true) + else + insert_at_start_of_selected_lines(comment_text, true) + end + end, + + ["doc:upper-case"] = function() + doc():replace(string.upper) + end, + + ["doc:lower-case"] = function() + doc():replace(string.lower) + end, + + ["doc:go-to-line"] = function() + local dv = dv() + + local items + local function init_items() + if items then return end + items = {} + local mt = { __tostring = function(x) return x.text end } + for i, line in ipairs(dv.doc.lines) do + local item = { text = line:sub(1, -2), line = i, info = "line: " .. i } + table.insert(items, setmetatable(item, mt)) + end + end + + core.command_view:enter("Go To Line", function(text, item) + local line = item and item.line or tonumber(text) + if not line then + core.error("Invalid line number or unmatched string") + return + end + dv.doc:set_selection(line, 1 ) + dv:scroll_to_line(line, true) + + end, function(text) + if not text:find("^%d*$") then + init_items() + return common.fuzzy_match(items, text) + end + end) + end, + + ["doc:toggle-line-ending"] = function() + doc().crlf = not doc().crlf + end, + + ["doc:save-as"] = function() + if doc().filename then + core.command_view:set_text(doc().filename) + end + core.command_view:enter("Save As", function(filename) + save(filename) + end, common.path_suggest) + end, + + ["doc:save"] = function() + if doc().filename then + save() + else + command.perform("doc:save-as") + end + end, + + ["doc:rename"] = function() + local old_filename = doc().filename + if not old_filename then + core.error("Cannot rename unsaved doc") + return + end + core.command_view:set_text(old_filename) + core.command_view:enter("Rename", function(filename) + doc():save(filename) + core.log("Renamed \"%s\" to \"%s\"", old_filename, filename) + if filename ~= old_filename then + os.remove(old_filename) + end + end, common.path_suggest) + end, +} + + +local translations = { + ["previous-char"] = translate.previous_char, + ["next-char"] = translate.next_char, + ["previous-word-start"] = translate.previous_word_start, + ["next-word-end"] = translate.next_word_end, + ["previous-block-start"] = translate.previous_block_start, + ["next-block-end"] = translate.next_block_end, + ["start-of-doc"] = translate.start_of_doc, + ["end-of-doc"] = translate.end_of_doc, + ["start-of-line"] = translate.start_of_line, + ["end-of-line"] = translate.end_of_line, + ["start-of-word"] = translate.start_of_word, + ["end-of-word"] = translate.end_of_word, + ["previous-line"] = DocView.translate.previous_line, + ["next-line"] = DocView.translate.next_line, + ["previous-page"] = DocView.translate.previous_page, + ["next-page"] = DocView.translate.next_page, +} + +for name, fn in pairs(translations) do + commands["doc:move-to-" .. name] = function() doc():move_to(fn, dv()) end + commands["doc:select-to-" .. name] = function() doc():select_to(fn, dv()) end + commands["doc:delete-to-" .. name] = function() doc():delete_to(fn, dv()) end +end + +commands["doc:move-to-previous-char"] = function() + if doc():has_selection() then + local line, col = doc():get_selection(true) + doc():set_selection(line, col) + else + doc():move_to(translate.previous_char) + end +end + +commands["doc:move-to-next-char"] = function() + if doc():has_selection() then + local _, _, line, col = doc():get_selection(true) + doc():set_selection(line, col) + else + doc():move_to(translate.next_char) + end +end + +command.add("core.docview", commands) diff --git a/tools/lite/data/core/commands/findreplace.lua b/tools/lite/data/core/commands/findreplace.lua new file mode 100644 index 0000000..937c410 --- /dev/null +++ b/tools/lite/data/core/commands/findreplace.lua @@ -0,0 +1,170 @@ +local core = require "core" +local command = require "core.command" +local config = require "core.config" +local search = require "core.doc.search" +local DocView = require "core.docview" + +local max_previous_finds = 50 + + +local function doc() + return core.active_view.doc +end + + +local previous_finds +local last_doc +local last_fn, last_text + + +local function push_previous_find(doc, sel) + if last_doc ~= doc then + last_doc = doc + previous_finds = {} + end + if #previous_finds >= max_previous_finds then + table.remove(previous_finds, 1) + end + table.insert(previous_finds, sel or { doc:get_selection() }) +end + + +local function find(label, search_fn) + local dv = core.active_view + local sel = { dv.doc:get_selection() } + local text = dv.doc:get_text(table.unpack(sel)) + local found = false + + core.command_view:set_text(text, true) + + core.command_view:enter(label, function(text) + if found then + last_fn, last_text = search_fn, text + previous_finds = {} + push_previous_find(dv.doc, sel) + else + core.error("Couldn't find %q", text) + dv.doc:set_selection(table.unpack(sel)) + dv:scroll_to_make_visible(sel[1], sel[2]) + end + + end, function(text) + local ok, line1, col1, line2, col2 = pcall(search_fn, dv.doc, sel[1], sel[2], text) + if ok and line1 and text ~= "" then + dv.doc:set_selection(line2, col2, line1, col1) + dv:scroll_to_line(line2, true) + found = true + else + dv.doc:set_selection(table.unpack(sel)) + found = false + end + + end, function(explicit) + if explicit then + dv.doc:set_selection(table.unpack(sel)) + dv:scroll_to_make_visible(sel[1], sel[2]) + end + end) +end + + +local function replace(kind, default, fn) + core.command_view:set_text(default, true) + + core.command_view:enter("Find To Replace " .. kind, function(old) + core.command_view:set_text(old, true) + + local s = string.format("Replace %s %q With", kind, old) + core.command_view:enter(s, function(new) + local n = doc():replace(function(text) + return fn(text, old, new) + end) + core.log("Replaced %d instance(s) of %s %q with %q", n, kind, old, new) + end) + end) +end + + +local function has_selection() + return core.active_view:is(DocView) + and core.active_view.doc:has_selection() +end + +command.add(has_selection, { + ["find-replace:select-next"] = function() + local l1, c1, l2, c2 = doc():get_selection(true) + local text = doc():get_text(l1, c1, l2, c2) + local l1, c1, l2, c2 = search.find(doc(), l2, c2, text, { wrap = true }) + if l2 then doc():set_selection(l2, c2, l1, c1) end + end +}) + +command.add("core.docview", { + ["find-replace:find"] = function() + find("Find Text", function(doc, line, col, text) + local opt = { wrap = true, no_case = true } + return search.find(doc, line, col, text, opt) + end) + end, + + ["find-replace:find-pattern"] = function() + find("Find Text Pattern", function(doc, line, col, text) + local opt = { wrap = true, no_case = true, pattern = true } + return search.find(doc, line, col, text, opt) + end) + end, + + ["find-replace:repeat-find"] = function() + if not last_fn then + core.error("No find to continue from") + else + local line, col = doc():get_selection() + local line1, col1, line2, col2 = last_fn(doc(), line, col, last_text) + if line1 then + push_previous_find(doc()) + doc():set_selection(line2, col2, line1, col1) + core.active_view:scroll_to_line(line2, true) + end + end + end, + + ["find-replace:previous-find"] = function() + local sel = table.remove(previous_finds) + if not sel or doc() ~= last_doc then + core.error("No previous finds") + return + end + doc():set_selection(table.unpack(sel)) + core.active_view:scroll_to_line(sel[3], true) + end, + + ["find-replace:replace"] = function() + replace("Text", "", function(text, old, new) + return text:gsub(old:gsub("%W", "%%%1"), new:gsub("%%", "%%%%"), nil) + end) + end, + + ["find-replace:replace-pattern"] = function() + replace("Pattern", "", function(text, old, new) + return text:gsub(old, new) + end) + end, + + ["find-replace:replace-symbol"] = function() + local first = "" + if doc():has_selection() then + local text = doc():get_text(doc():get_selection()) + first = text:match(config.symbol_pattern) or "" + end + replace("Symbol", first, function(text, old, new) + local n = 0 + local res = text:gsub(config.symbol_pattern, function(sym) + if old == sym then + n = n + 1 + return new + end + end) + return res, n + end) + end, +}) diff --git a/tools/lite/data/core/commands/root.lua b/tools/lite/data/core/commands/root.lua new file mode 100644 index 0000000..58c83e2 --- /dev/null +++ b/tools/lite/data/core/commands/root.lua @@ -0,0 +1,105 @@ +local core = require "core" +local style = require "core.style" +local DocView = require "core.docview" +local command = require "core.command" +local common = require "core.common" + + +local t = { + ["root:close"] = function() + local node = core.root_view:get_active_node() + node:close_active_view(core.root_view.root_node) + end, + + ["root:switch-to-previous-tab"] = function() + local node = core.root_view:get_active_node() + local idx = node:get_view_idx(core.active_view) + idx = idx - 1 + if idx < 1 then idx = #node.views end + node:set_active_view(node.views[idx]) + end, + + ["root:switch-to-next-tab"] = function() + local node = core.root_view:get_active_node() + local idx = node:get_view_idx(core.active_view) + idx = idx + 1 + if idx > #node.views then idx = 1 end + node:set_active_view(node.views[idx]) + end, + + ["root:move-tab-left"] = function() + local node = core.root_view:get_active_node() + local idx = node:get_view_idx(core.active_view) + if idx > 1 then + table.remove(node.views, idx) + table.insert(node.views, idx - 1, core.active_view) + end + end, + + ["root:move-tab-right"] = function() + local node = core.root_view:get_active_node() + local idx = node:get_view_idx(core.active_view) + if idx < #node.views then + table.remove(node.views, idx) + table.insert(node.views, idx + 1, core.active_view) + end + end, + + ["root:shrink"] = function() + local node = core.root_view:get_active_node() + local parent = node:get_parent_node(core.root_view.root_node) + local n = (parent.a == node) and -0.1 or 0.1 + parent.divider = common.clamp(parent.divider + n, 0.1, 0.9) + end, + + ["root:grow"] = function() + local node = core.root_view:get_active_node() + local parent = node:get_parent_node(core.root_view.root_node) + local n = (parent.a == node) and 0.1 or -0.1 + parent.divider = common.clamp(parent.divider + n, 0.1, 0.9) + end, +} + + +for i = 1, 9 do + t["root:switch-to-tab-" .. i] = function() + local node = core.root_view:get_active_node() + local view = node.views[i] + if view then + node:set_active_view(view) + end + end +end + + +for _, dir in ipairs { "left", "right", "up", "down" } do + t["root:split-" .. dir] = function() + local node = core.root_view:get_active_node() + local av = node.active_view + node:split(dir) + if av:is(DocView) then + core.root_view:open_doc(av.doc) + end + end + + t["root:switch-to-" .. dir] = function() + local node = core.root_view:get_active_node() + local x, y + if dir == "left" or dir == "right" then + y = node.position.y + node.size.y / 2 + x = node.position.x + (dir == "left" and -1 or node.size.x + style.divider_size) + else + x = node.position.x + node.size.x / 2 + y = node.position.y + (dir == "up" and -1 or node.size.y + style.divider_size) + end + local node = core.root_view.root_node:get_child_overlapping_point(x, y) + if not node:get_locked_size() then + core.set_active_view(node.active_view) + end + end +end + +command.add(function() + local node = core.root_view:get_active_node() + return not node:get_locked_size() +end, t) diff --git a/tools/lite/data/core/commandview.lua b/tools/lite/data/core/commandview.lua new file mode 100644 index 0000000..3fbb1ff --- /dev/null +++ b/tools/lite/data/core/commandview.lua @@ -0,0 +1,256 @@ +local core = require "core" +local common = require "core.common" +local style = require "core.style" +local Doc = require "core.doc" +local DocView = require "core.docview" +local View = require "core.view" + + +local SingleLineDoc = Doc:extend() + +function SingleLineDoc:insert(line, col, text) + SingleLineDoc.super.insert(self, line, col, text:gsub("\n", "")) +end + + +local CommandView = DocView:extend() + +local max_suggestions = 10 + +local noop = function() end + +local default_state = { + submit = noop, + suggest = noop, + cancel = noop, +} + + +function CommandView:new() + CommandView.super.new(self, SingleLineDoc()) + self.suggestion_idx = 1 + self.suggestions = {} + self.suggestions_height = 0 + self.last_change_id = 0 + self.gutter_width = 0 + self.gutter_text_brightness = 0 + self.selection_offset = 0 + self.state = default_state + self.font = "font" + self.size.y = 0 + self.label = "" +end + + +function CommandView:get_name() + return View.get_name(self) +end + + +function CommandView:get_line_screen_position() + local x = CommandView.super.get_line_screen_position(self, 1) + local _, y = self:get_content_offset() + local lh = self:get_line_height() + return x, y + (self.size.y - lh) / 2 +end + + +function CommandView:get_scrollable_size() + return 0 +end + + +function CommandView:scroll_to_make_visible() + -- no-op function to disable this functionality +end + + +function CommandView:get_text() + return self.doc:get_text(1, 1, 1, math.huge) +end + + +function CommandView:set_text(text, select) + self.doc:remove(1, 1, math.huge, math.huge) + self.doc:text_input(text) + if select then + self.doc:set_selection(math.huge, math.huge, 1, 1) + end +end + + +function CommandView:move_suggestion_idx(dir) + local n = self.suggestion_idx + dir + self.suggestion_idx = common.clamp(n, 1, #self.suggestions) + self:complete() + self.last_change_id = self.doc:get_change_id() +end + + +function CommandView:complete() + if #self.suggestions > 0 then + self:set_text(self.suggestions[self.suggestion_idx].text) + end +end + + +function CommandView:submit() + local suggestion = self.suggestions[self.suggestion_idx] + local text = self:get_text() + local submit = self.state.submit + self:exit(true) + submit(text, suggestion) +end + + +function CommandView:enter(text, submit, suggest, cancel) + if self.state ~= default_state then + return + end + self.state = { + submit = submit or noop, + suggest = suggest or noop, + cancel = cancel or noop, + } + core.set_active_view(self) + self:update_suggestions() + self.gutter_text_brightness = 100 + self.label = text .. ": " +end + + +function CommandView:exit(submitted, inexplicit) + if core.active_view == self then + core.set_active_view(core.last_active_view) + end + local cancel = self.state.cancel + self.state = default_state + self.doc:reset() + self.suggestions = {} + if not submitted then cancel(not inexplicit) end +end + + +function CommandView:get_gutter_width() + return self.gutter_width +end + + +function CommandView:get_suggestion_line_height() + return self:get_font():get_height() + style.padding.y +end + + +function CommandView:update_suggestions() + local t = self.state.suggest(self:get_text()) or {} + local res = {} + for i, item in ipairs(t) do + if i == max_suggestions then + break + end + if type(item) == "string" then + item = { text = item } + end + res[i] = item + end + self.suggestions = res + self.suggestion_idx = 1 +end + + +function CommandView:update() + CommandView.super.update(self) + + if core.active_view ~= self and self.state ~= default_state then + self:exit(false, true) + end + + -- update suggestions if text has changed + if self.last_change_id ~= self.doc:get_change_id() then + self:update_suggestions() + self.last_change_id = self.doc:get_change_id() + end + + -- update gutter text color brightness + self:move_towards("gutter_text_brightness", 0, 0.1) + + -- update gutter width + local dest = self:get_font():get_width(self.label) + style.padding.x + if self.size.y <= 0 then + self.gutter_width = dest + else + self:move_towards("gutter_width", dest) + end + + -- update suggestions box height + local lh = self:get_suggestion_line_height() + local dest = #self.suggestions * lh + self:move_towards("suggestions_height", dest) + + -- update suggestion cursor offset + local dest = self.suggestion_idx * self:get_suggestion_line_height() + self:move_towards("selection_offset", dest) + + -- update size based on whether this is the active_view + local dest = 0 + if self == core.active_view then + dest = style.font:get_height() + style.padding.y * 2 + end + self:move_towards(self.size, "y", dest) +end + + +function CommandView:draw_line_highlight() + -- no-op function to disable this functionality +end + + +function CommandView:draw_line_gutter(idx, x, y) + local yoffset = self:get_line_text_y_offset() + local pos = self.position + local color = common.lerp(style.text, style.accent, self.gutter_text_brightness / 100) + core.push_clip_rect(pos.x, pos.y, self:get_gutter_width(), self.size.y) + x = x + style.padding.x + renderer.draw_text(self:get_font(), self.label, x, y + yoffset, color) + core.pop_clip_rect() +end + + +local function draw_suggestions_box(self) + local lh = self:get_suggestion_line_height() + local dh = style.divider_size + local x, _ = self:get_line_screen_position() + local h = math.ceil(self.suggestions_height) + local rx, ry, rw, rh = self.position.x, self.position.y - h - dh, self.size.x, h + + -- draw suggestions background + if #self.suggestions > 0 then + renderer.draw_rect(rx, ry, rw, rh, style.background3) + renderer.draw_rect(rx, ry - dh, rw, dh, style.divider) + local y = self.position.y - self.selection_offset - dh + renderer.draw_rect(rx, y, rw, lh, style.line_highlight) + end + + -- draw suggestion text + core.push_clip_rect(rx, ry, rw, rh) + for i, item in ipairs(self.suggestions) do + local color = (i == self.suggestion_idx) and style.accent or style.text + local y = self.position.y - i * lh - dh + common.draw_text(self:get_font(), color, item.text, nil, x, y, 0, lh) + + if item.info then + local w = self.size.x - x - style.padding.x + common.draw_text(self:get_font(), style.dim, item.info, "right", x, y, w, lh) + end + end + core.pop_clip_rect() +end + + +function CommandView:draw() + CommandView.super.draw(self) + core.root_view:defer_draw(draw_suggestions_box, self) +end + + +return CommandView diff --git a/tools/lite/data/core/common.lua b/tools/lite/data/core/common.lua new file mode 100644 index 0000000..1fc91b8 --- /dev/null +++ b/tools/lite/data/core/common.lua @@ -0,0 +1,140 @@ +local common = {} + + +function common.is_utf8_cont(char) + local byte = char:byte() + return byte >= 0x80 and byte < 0xc0 +end + + +function common.utf8_chars(text) + return text:gmatch("[\0-\x7f\xc2-\xf4][\x80-\xbf]*") +end + + +function common.clamp(n, lo, hi) + return math.max(math.min(n, hi), lo) +end + + +function common.round(n) + return n >= 0 and math.floor(n + 0.5) or math.ceil(n - 0.5) +end + + +function common.lerp(a, b, t) + if type(a) ~= "table" then + return a + (b - a) * t + end + local res = {} + for k, v in pairs(b) do + res[k] = common.lerp(a[k], v, t) + end + return res +end + + +function common.color(str) + local r, g, b, a = str:match("#(%x%x)(%x%x)(%x%x)") + if r then + r = tonumber(r, 16) + g = tonumber(g, 16) + b = tonumber(b, 16) + a = 1 + elseif str:match("rgba?%s*%([%d%s%.,]+%)") then + local f = str:gmatch("[%d.]+") + r = (f() or 0) + g = (f() or 0) + b = (f() or 0) + a = f() or 1 + else + error(string.format("bad color string '%s'", str)) + end + return r, g, b, a * 0xff +end + + +local function compare_score(a, b) + return a.score > b.score +end + +local function fuzzy_match_items(items, needle) + local res = {} + for _, item in ipairs(items) do + local score = system.fuzzy_match(tostring(item), needle) + if score then + table.insert(res, { text = item, score = score }) + end + end + table.sort(res, compare_score) + for i, item in ipairs(res) do + res[i] = item.text + end + return res +end + + +function common.fuzzy_match(haystack, needle) + if type(haystack) == "table" then + return fuzzy_match_items(haystack, needle) + end + return system.fuzzy_match(haystack, needle) +end + + +function common.path_suggest(text) + local path, name = text:match("^(.-)([^/\\]*)$") + local files = system.list_dir(path == "" and "." or path) or {} + local res = {} + for _, file in ipairs(files) do + file = path .. file + local info = system.get_file_info(file) + if info then + if info.type == "dir" then + file = file .. PATHSEP + end + if file:lower():find(text:lower(), nil, true) == 1 then + table.insert(res, file) + end + end + end + return res +end + + +function common.match_pattern(text, pattern, ...) + if type(pattern) == "string" then + return text:find(pattern, ...) + end + for _, p in ipairs(pattern) do + local s, e = common.match_pattern(text, p, ...) + if s then return s, e end + end + return false +end + + +function common.draw_text(font, color, text, align, x,y,w,h) + local tw, th = font:get_width(text), font:get_height(text) + if align == "center" then + x = x + (w - tw) / 2 + elseif align == "right" then + x = x + (w - tw) + end + y = common.round(y + (h - th) / 2) + return renderer.draw_text(font, text, x, y, color), y + th +end + + +function common.bench(name, fn, ...) + local start = system.get_time() + local res = fn(...) + local t = system.get_time() - start + local ms = t * 1000 + local per = (t / (1 / 60)) * 100 + print(string.format("*** %-16s : %8.3fms %6.2f%%", name, ms, per)) + return res +end + + +return common diff --git a/tools/lite/data/core/config.lua b/tools/lite/data/core/config.lua new file mode 100644 index 0000000..a9f106f --- /dev/null +++ b/tools/lite/data/core/config.lua @@ -0,0 +1,20 @@ +local config = {} + +config.project_scan_rate = 5 +config.fps = 60 +config.max_log_items = 80 +config.message_timeout = 3 +config.mouse_wheel_scroll = 50 * SCALE +config.file_size_limit = 10 +config.ignore_files = "^%." +config.symbol_pattern = "[%a_][%w_]*" +config.non_word_chars = " \t\n/\\()\"':,.;<>~!@#$%^&*|+=[]{}`?-" +config.undo_merge_timeout = 0.3 +config.max_undos = 10000 +config.highlight_current_line = true +config.line_height = 1.2 +config.indent_size = 2 +config.tab_type = "soft" +config.line_limit = 80 + +return config diff --git a/tools/lite/data/core/doc/highlighter.lua b/tools/lite/data/core/doc/highlighter.lua new file mode 100644 index 0000000..e7650d0 --- /dev/null +++ b/tools/lite/data/core/doc/highlighter.lua @@ -0,0 +1,80 @@ +local core = require "core" +local config = require "core.config" +local tokenizer = require "core.tokenizer" +local Object = require "core.object" + + +local Highlighter = Object:extend() + + +function Highlighter:new(doc) + self.doc = doc + self:reset() + + -- init incremental syntax highlighting + core.add_thread(function() + while true do + if self.first_invalid_line > self.max_wanted_line then + self.max_wanted_line = 0 + coroutine.yield(1 / config.fps) + + else + local max = math.min(self.first_invalid_line + 40, self.max_wanted_line) + + for i = self.first_invalid_line, max do + local state = (i > 1) and self.lines[i - 1].state + local line = self.lines[i] + if not (line and line.init_state == state) then + self.lines[i] = self:tokenize_line(i, state) + end + end + + self.first_invalid_line = max + 1 + core.redraw = true + coroutine.yield() + end + end + end, self) +end + + +function Highlighter:reset() + self.lines = {} + self.first_invalid_line = 1 + self.max_wanted_line = 0 +end + + +function Highlighter:invalidate(idx) + self.first_invalid_line = math.min(self.first_invalid_line, idx) + self.max_wanted_line = math.min(self.max_wanted_line, #self.doc.lines) +end + + +function Highlighter:tokenize_line(idx, state) + local res = {} + res.init_state = state + res.text = self.doc.lines[idx] + res.tokens, res.state = tokenizer.tokenize(self.doc.syntax, res.text, state) + return res +end + + +function Highlighter:get_line(idx) + local line = self.lines[idx] + if not line or line.text ~= self.doc.lines[idx] then + local prev = self.lines[idx - 1] + line = self:tokenize_line(idx, prev and prev.state) + self.lines[idx] = line + end + self.max_wanted_line = math.max(self.max_wanted_line, idx) + return line +end + + +function Highlighter:each_token(idx) + return tokenizer.each_token(self:get_line(idx).tokens) +end + + +return Highlighter diff --git a/tools/lite/data/core/doc/init.lua b/tools/lite/data/core/doc/init.lua new file mode 100644 index 0000000..83b5fc0 --- /dev/null +++ b/tools/lite/data/core/doc/init.lua @@ -0,0 +1,393 @@ +local Object = require "core.object" +local Highlighter = require "core.doc.highlighter" +local syntax = require "core.syntax" +local config = require "core.config" +local common = require "core.common" + + +local Doc = Object:extend() + + +local function split_lines(text) + local res = {} + for line in (text .. "\n"):gmatch("(.-)\n") do + table.insert(res, line) + end + return res +end + + +local function splice(t, at, remove, insert) + insert = insert or {} + local offset = #insert - remove + local old_len = #t + if offset < 0 then + for i = at - offset, old_len - offset do + t[i + offset] = t[i] + end + elseif offset > 0 then + for i = old_len, at, -1 do + t[i + offset] = t[i] + end + end + for i, item in ipairs(insert) do + t[at + i - 1] = item + end +end + + +function Doc:new(filename) + self:reset() + if filename then + self:load(filename) + end +end + + +function Doc:reset() + self.lines = { "\n" } + self.selection = { a = { line=1, col=1 }, b = { line=1, col=1 } } + self.undo_stack = { idx = 1 } + self.redo_stack = { idx = 1 } + self.clean_change_id = 1 + self.highlighter = Highlighter(self) + self:reset_syntax() +end + + +function Doc:reset_syntax() + local header = self:get_text(1, 1, self:position_offset(1, 1, 128)) + local syn = syntax.get(self.filename or "", header) + if self.syntax ~= syn then + self.syntax = syn + self.highlighter:reset() + end +end + + +function Doc:load(filename) + local fp = assert( io.open(filename, "rb") ) + self:reset() + self.filename = filename + self.lines = {} + for line in fp:lines() do + if line:byte(-1) == 13 then + line = line:sub(1, -2) + self.crlf = true + end + table.insert(self.lines, line .. "\n") + end + if #self.lines == 0 then + table.insert(self.lines, "\n") + end + fp:close() + self:reset_syntax() +end + + +function Doc:save(filename) + filename = filename or assert(self.filename, "no filename set to default to") + local fp = assert( io.open(filename, "wb") ) + for _, line in ipairs(self.lines) do + if self.crlf then line = line:gsub("\n", "\r\n") end + fp:write(line) + end + fp:close() + self.filename = filename or self.filename + self:reset_syntax() + self:clean() +end + + +function Doc:get_name() + return self.filename or "unsaved" +end + + +function Doc:is_dirty() + return self.clean_change_id ~= self:get_change_id() +end + + +function Doc:clean() + self.clean_change_id = self:get_change_id() +end + + +function Doc:get_change_id() + return self.undo_stack.idx +end + + +function Doc:set_selection(line1, col1, line2, col2, swap) + assert(not line2 == not col2, "expected 2 or 4 arguments") + if swap then line1, col1, line2, col2 = line2, col2, line1, col1 end + line1, col1 = self:sanitize_position(line1, col1) + line2, col2 = self:sanitize_position(line2 or line1, col2 or col1) + self.selection.a.line, self.selection.a.col = line1, col1 + self.selection.b.line, self.selection.b.col = line2, col2 +end + + +local function sort_positions(line1, col1, line2, col2) + if line1 > line2 + or line1 == line2 and col1 > col2 then + return line2, col2, line1, col1, true + end + return line1, col1, line2, col2, false +end + + +function Doc:get_selection(sort) + local a, b = self.selection.a, self.selection.b + if sort then + return sort_positions(a.line, a.col, b.line, b.col) + end + return a.line, a.col, b.line, b.col +end + + +function Doc:has_selection() + local a, b = self.selection.a, self.selection.b + return not (a.line == b.line and a.col == b.col) +end + + +function Doc:sanitize_selection() + self:set_selection(self:get_selection()) +end + + +function Doc:sanitize_position(line, col) + line = common.clamp(line, 1, #self.lines) + col = common.clamp(col, 1, #self.lines[line]) + return line, col +end + + +local function position_offset_func(self, line, col, fn, ...) + line, col = self:sanitize_position(line, col) + return fn(self, line, col, ...) +end + + +local function position_offset_byte(self, line, col, offset) + line, col = self:sanitize_position(line, col) + col = col + offset + while line > 1 and col < 1 do + line = line - 1 + col = col + #self.lines[line] + end + while line < #self.lines and col > #self.lines[line] do + col = col - #self.lines[line] + line = line + 1 + end + return self:sanitize_position(line, col) +end + + +local function position_offset_linecol(self, line, col, lineoffset, coloffset) + return self:sanitize_position(line + lineoffset, col + coloffset) +end + + +function Doc:position_offset(line, col, ...) + if type(...) ~= "number" then + return position_offset_func(self, line, col, ...) + elseif select("#", ...) == 1 then + return position_offset_byte(self, line, col, ...) + elseif select("#", ...) == 2 then + return position_offset_linecol(self, line, col, ...) + else + error("bad number of arguments") + end +end + + +function Doc:get_text(line1, col1, line2, col2) + line1, col1 = self:sanitize_position(line1, col1) + line2, col2 = self:sanitize_position(line2, col2) + line1, col1, line2, col2 = sort_positions(line1, col1, line2, col2) + if line1 == line2 then + return self.lines[line1]:sub(col1, col2 - 1) + end + local lines = { self.lines[line1]:sub(col1) } + for i = line1 + 1, line2 - 1 do + table.insert(lines, self.lines[i]) + end + table.insert(lines, self.lines[line2]:sub(1, col2 - 1)) + return table.concat(lines) +end + + +function Doc:get_char(line, col) + line, col = self:sanitize_position(line, col) + return self.lines[line]:sub(col, col) +end + + +local function push_undo(undo_stack, time, type, ...) + undo_stack[undo_stack.idx] = { type = type, time = time, ... } + undo_stack[undo_stack.idx - config.max_undos] = nil + undo_stack.idx = undo_stack.idx + 1 +end + + +local function pop_undo(self, undo_stack, redo_stack) + -- pop command + local cmd = undo_stack[undo_stack.idx - 1] + if not cmd then return end + undo_stack.idx = undo_stack.idx - 1 + + -- handle command + if cmd.type == "insert" then + local line, col, text = table.unpack(cmd) + self:raw_insert(line, col, text, redo_stack, cmd.time) + + elseif cmd.type == "remove" then + local line1, col1, line2, col2 = table.unpack(cmd) + self:raw_remove(line1, col1, line2, col2, redo_stack, cmd.time) + + elseif cmd.type == "selection" then + self.selection.a.line, self.selection.a.col = cmd[1], cmd[2] + self.selection.b.line, self.selection.b.col = cmd[3], cmd[4] + end + + -- if next undo command is within the merge timeout then treat as a single + -- command and continue to execute it + local next = undo_stack[undo_stack.idx - 1] + if next and math.abs(cmd.time - next.time) < config.undo_merge_timeout then + return pop_undo(self, undo_stack, redo_stack) + end +end + + +function Doc:raw_insert(line, col, text, undo_stack, time) + -- split text into lines and merge with line at insertion point + local lines = split_lines(text) + local before = self.lines[line]:sub(1, col - 1) + local after = self.lines[line]:sub(col) + for i = 1, #lines - 1 do + lines[i] = lines[i] .. "\n" + end + lines[1] = before .. lines[1] + lines[#lines] = lines[#lines] .. after + + -- splice lines into line array + splice(self.lines, line, 1, lines) + + -- push undo + local line2, col2 = self:position_offset(line, col, #text) + push_undo(undo_stack, time, "selection", self:get_selection()) + push_undo(undo_stack, time, "remove", line, col, line2, col2) + + -- update highlighter and assure selection is in bounds + self.highlighter:invalidate(line) + self:sanitize_selection() +end + + +function Doc:raw_remove(line1, col1, line2, col2, undo_stack, time) + -- push undo + local text = self:get_text(line1, col1, line2, col2) + push_undo(undo_stack, time, "selection", self:get_selection()) + push_undo(undo_stack, time, "insert", line1, col1, text) + + -- get line content before/after removed text + local before = self.lines[line1]:sub(1, col1 - 1) + local after = self.lines[line2]:sub(col2) + + -- splice line into line array + splice(self.lines, line1, line2 - line1 + 1, { before .. after }) + + -- update highlighter and assure selection is in bounds + self.highlighter:invalidate(line1) + self:sanitize_selection() +end + + +function Doc:insert(line, col, text) + self.redo_stack = { idx = 1 } + line, col = self:sanitize_position(line, col) + self:raw_insert(line, col, text, self.undo_stack, system.get_time()) +end + + +function Doc:remove(line1, col1, line2, col2) + self.redo_stack = { idx = 1 } + line1, col1 = self:sanitize_position(line1, col1) + line2, col2 = self:sanitize_position(line2, col2) + line1, col1, line2, col2 = sort_positions(line1, col1, line2, col2) + self:raw_remove(line1, col1, line2, col2, self.undo_stack, system.get_time()) +end + + +function Doc:undo() + pop_undo(self, self.undo_stack, self.redo_stack) +end + + +function Doc:redo() + pop_undo(self, self.redo_stack, self.undo_stack) +end + + +function Doc:text_input(text) + if self:has_selection() then + self:delete_to() + end + local line, col = self:get_selection() + self:insert(line, col, text) + self:move_to(#text) +end + + +function Doc:replace(fn) + local line1, col1, line2, col2, swap + local had_selection = self:has_selection() + if had_selection then + line1, col1, line2, col2, swap = self:get_selection(true) + else + line1, col1, line2, col2 = 1, 1, #self.lines, #self.lines[#self.lines] + end + local old_text = self:get_text(line1, col1, line2, col2) + local new_text, n = fn(old_text) + if old_text ~= new_text then + self:insert(line2, col2, new_text) + self:remove(line1, col1, line2, col2) + if had_selection then + line2, col2 = self:position_offset(line1, col1, #new_text) + self:set_selection(line1, col1, line2, col2, swap) + end + end + return n +end + + +function Doc:delete_to(...) + local line, col = self:get_selection(true) + if self:has_selection() then + self:remove(self:get_selection()) + else + local line2, col2 = self:position_offset(line, col, ...) + self:remove(line, col, line2, col2) + line, col = sort_positions(line, col, line2, col2) + end + self:set_selection(line, col) +end + + +function Doc:move_to(...) + local line, col = self:get_selection() + self:set_selection(self:position_offset(line, col, ...)) +end + + +function Doc:select_to(...) + local line, col, line2, col2 = self:get_selection() + line, col = self:position_offset(line, col, ...) + self:set_selection(line, col, line2, col2) +end + + +return Doc diff --git a/tools/lite/data/core/doc/search.lua b/tools/lite/data/core/doc/search.lua new file mode 100644 index 0000000..fe57523 --- /dev/null +++ b/tools/lite/data/core/doc/search.lua @@ -0,0 +1,52 @@ +local search = {} + +local default_opt = {} + + +local function pattern_lower(str) + if str:sub(1, 1) == "%" then + return str + end + return str:lower() +end + + +local function init_args(doc, line, col, text, opt) + opt = opt or default_opt + line, col = doc:sanitize_position(line, col) + + if opt.no_case then + if opt.pattern then + text = text:gsub("%%?.", pattern_lower) + else + text = text:lower() + end + end + + return doc, line, col, text, opt +end + + +function search.find(doc, line, col, text, opt) + doc, line, col, text, opt = init_args(doc, line, col, text, opt) + + for line = line, #doc.lines do + local line_text = doc.lines[line] + if opt.no_case then + line_text = line_text:lower() + end + local s, e = line_text:find(text, col, not opt.pattern) + if s then + return line, s, line, e + 1 + end + col = 1 + end + + if opt.wrap then + opt = { no_case = opt.no_case, pattern = opt.pattern } + return search.find(doc, 1, 1, text, opt) + end +end + + +return search diff --git a/tools/lite/data/core/doc/translate.lua b/tools/lite/data/core/doc/translate.lua new file mode 100644 index 0000000..b084e89 --- /dev/null +++ b/tools/lite/data/core/doc/translate.lua @@ -0,0 +1,136 @@ +local common = require "core.common" +local config = require "core.config" + +-- functions for translating a Doc position to another position these functions +-- can be passed to Doc:move_to|select_to|delete_to() + +local translate = {} + + +local function is_non_word(char) + return config.non_word_chars:find(char, nil, true) +end + + +function translate.previous_char(doc, line, col) + repeat + line, col = doc:position_offset(line, col, -1) + until not common.is_utf8_cont(doc:get_char(line, col)) + return line, col +end + + +function translate.next_char(doc, line, col) + repeat + line, col = doc:position_offset(line, col, 1) + until not common.is_utf8_cont(doc:get_char(line, col)) + return line, col +end + + +function translate.previous_word_start(doc, line, col) + local prev + while line > 1 or col > 1 do + local l, c = doc:position_offset(line, col, -1) + local char = doc:get_char(l, c) + if prev and prev ~= char or not is_non_word(char) then + break + end + prev, line, col = char, l, c + end + return translate.start_of_word(doc, line, col) +end + + +function translate.next_word_end(doc, line, col) + local prev + local end_line, end_col = translate.end_of_doc(doc, line, col) + while line < end_line or col < end_col do + local char = doc:get_char(line, col) + if prev and prev ~= char or not is_non_word(char) then + break + end + line, col = doc:position_offset(line, col, 1) + prev = char + end + return translate.end_of_word(doc, line, col) +end + + +function translate.start_of_word(doc, line, col) + while true do + local line2, col2 = doc:position_offset(line, col, -1) + local char = doc:get_char(line2, col2) + if is_non_word(char) + or line == line2 and col == col2 then + break + end + line, col = line2, col2 + end + return line, col +end + + +function translate.end_of_word(doc, line, col) + while true do + local line2, col2 = doc:position_offset(line, col, 1) + local char = doc:get_char(line, col) + if is_non_word(char) + or line == line2 and col == col2 then + break + end + line, col = line2, col2 + end + return line, col +end + + +function translate.previous_block_start(doc, line, col) + while true do + line = line - 1 + if line <= 1 then + return 1, 1 + end + if doc.lines[line-1]:find("^%s*$") + and not doc.lines[line]:find("^%s*$") then + return line, (doc.lines[line]:find("%S")) + end + end +end + + +function translate.next_block_end(doc, line, col) + while true do + if line >= #doc.lines then + return #doc.lines, 1 + end + if doc.lines[line+1]:find("^%s*$") + and not doc.lines[line]:find("^%s*$") then + return line+1, #doc.lines[line+1] + end + line = line + 1 + end +end + + +function translate.start_of_line(doc, line, col) + return line, 1 +end + + +function translate.end_of_line(doc, line, col) + return line, math.huge +end + + +function translate.start_of_doc(doc, line, col) + return 1, 1 +end + + +function translate.end_of_doc(doc, line, col) + return #doc.lines, #doc.lines[#doc.lines] +end + + +return translate diff --git a/tools/lite/data/core/docview.lua b/tools/lite/data/core/docview.lua new file mode 100644 index 0000000..73191c2 --- /dev/null +++ b/tools/lite/data/core/docview.lua @@ -0,0 +1,383 @@ +local core = require "core" +local common = require "core.common" +local config = require "core.config" +local style = require "core.style" +local keymap = require "core.keymap" +local translate = require "core.doc.translate" +local View = require "core.view" + + +local DocView = View:extend() + + +local function move_to_line_offset(dv, line, col, offset) + local xo = dv.last_x_offset + if xo.line ~= line or xo.col ~= col then + xo.offset = dv:get_col_x_offset(line, col) + end + xo.line = line + offset + xo.col = dv:get_x_offset_col(line + offset, xo.offset) + return xo.line, xo.col +end + + +DocView.translate = { + ["previous_page"] = function(doc, line, col, dv) + local min, max = dv:get_visible_line_range() + return line - (max - min), 1 + end, + + ["next_page"] = function(doc, line, col, dv) + local min, max = dv:get_visible_line_range() + return line + (max - min), 1 + end, + + ["previous_line"] = function(doc, line, col, dv) + if line == 1 then + return 1, 1 + end + return move_to_line_offset(dv, line, col, -1) + end, + + ["next_line"] = function(doc, line, col, dv) + if line == #doc.lines then + return #doc.lines, math.huge + end + return move_to_line_offset(dv, line, col, 1) + end, +} + +local blink_period = 0.8 + + +function DocView:new(doc) + DocView.super.new(self) + self.cursor = "ibeam" + self.scrollable = true + self.doc = assert(doc) + self.font = "code_font" + self.last_x_offset = {} + self.blink_timer = 0 +end + + +function DocView:try_close(do_close) + if self.doc:is_dirty() + and #core.get_views_referencing_doc(self.doc) == 1 then + core.command_view:enter("Unsaved Changes; Confirm Close", function(_, item) + if item.text:match("^[cC]") then + do_close() + elseif item.text:match("^[sS]") then + self.doc:save() + do_close() + end + end, function(text) + local items = {} + if not text:find("^[^cC]") then table.insert(items, "Close Without Saving") end + if not text:find("^[^sS]") then table.insert(items, "Save And Close") end + return items + end) + else + do_close() + end +end + + +function DocView:get_name() + local post = self.doc:is_dirty() and "*" or "" + local name = self.doc:get_name() + return name:match("[^/%\\]*$") .. post +end + + +function DocView:get_scrollable_size() + return self:get_line_height() * (#self.doc.lines - 1) + self.size.y +end + + +function DocView:get_font() + return style[self.font] +end + + +function DocView:get_line_height() + return math.floor(self:get_font():get_height() * config.line_height) +end + + +function DocView:get_gutter_width() + return self:get_font():get_width(#self.doc.lines) + style.padding.x * 2 +end + + +function DocView:get_line_screen_position(idx) + local x, y = self:get_content_offset() + local lh = self:get_line_height() + local gw = self:get_gutter_width() + return x + gw, y + (idx-1) * lh + style.padding.y +end + + +function DocView:get_line_text_y_offset() + local lh = self:get_line_height() + local th = self:get_font():get_height() + return (lh - th) / 2 +end + + +function DocView:get_visible_line_range() + local x, y, x2, y2 = self:get_content_bounds() + local lh = self:get_line_height() + local minline = math.max(1, math.floor(y / lh)) + local maxline = math.min(#self.doc.lines, math.floor(y2 / lh) + 1) + return minline, maxline +end + + +function DocView:get_col_x_offset(line, col) + local text = self.doc.lines[line] + if not text then return 0 end + return self:get_font():get_width(text:sub(1, col - 1)) +end + + +function DocView:get_x_offset_col(line, x) + local text = self.doc.lines[line] + + local xoffset, last_i, i = 0, 1, 1 + for char in common.utf8_chars(text) do + local w = self:get_font():get_width(char) + if xoffset >= x then + return (xoffset - x > w / 2) and last_i or i + end + xoffset = xoffset + w + last_i = i + i = i + #char + end + + return #text +end + + +function DocView:resolve_screen_position(x, y) + local ox, oy = self:get_line_screen_position(1) + local line = math.floor((y - oy) / self:get_line_height()) + 1 + line = common.clamp(line, 1, #self.doc.lines) + local col = self:get_x_offset_col(line, x - ox) + return line, col +end + + +function DocView:scroll_to_line(line, ignore_if_visible, instant) + local min, max = self:get_visible_line_range() + if not (ignore_if_visible and line > min and line < max) then + local lh = self:get_line_height() + self.scroll.to.y = math.max(0, lh * (line - 1) - self.size.y / 2) + if instant then + self.scroll.y = self.scroll.to.y + end + end +end + + +function DocView:scroll_to_make_visible(line, col) + local min = self:get_line_height() * (line - 1) + local max = self:get_line_height() * (line + 2) - self.size.y + self.scroll.to.y = math.min(self.scroll.to.y, min) + self.scroll.to.y = math.max(self.scroll.to.y, max) + local gw = self:get_gutter_width() + local xoffset = self:get_col_x_offset(line, col) + local max = xoffset - self.size.x + gw + self.size.x / 5 + self.scroll.to.x = math.max(0, max) +end + + +local function mouse_selection(doc, clicks, line1, col1, line2, col2) + local swap = line2 < line1 or line2 == line1 and col2 <= col1 + if swap then + line1, col1, line2, col2 = line2, col2, line1, col1 + end + if clicks == 2 then + line1, col1 = translate.start_of_word(doc, line1, col1) + line2, col2 = translate.end_of_word(doc, line2, col2) + elseif clicks == 3 then + if line2 == #doc.lines and doc.lines[#doc.lines] ~= "\n" then + doc:insert(math.huge, math.huge, "\n") + end + line1, col1, line2, col2 = line1, 1, line2 + 1, 1 + end + if swap then + return line2, col2, line1, col1 + end + return line1, col1, line2, col2 +end + + +function DocView:on_mouse_pressed(button, x, y, clicks) + local caught = DocView.super.on_mouse_pressed(self, button, x, y, clicks) + if caught then + return + end + if keymap.modkeys["shift"] then + if clicks == 1 then + local line1, col1 = select(3, self.doc:get_selection()) + local line2, col2 = self:resolve_screen_position(x, y) + self.doc:set_selection(line2, col2, line1, col1) + end + else + local line, col = self:resolve_screen_position(x, y) + self.doc:set_selection(mouse_selection(self.doc, clicks, line, col, line, col)) + self.mouse_selecting = { line, col, clicks = clicks } + end + self.blink_timer = 0 +end + + +function DocView:on_mouse_moved(x, y, ...) + DocView.super.on_mouse_moved(self, x, y, ...) + + if self:scrollbar_overlaps_point(x, y) or self.dragging_scrollbar then + self.cursor = "arrow" + else + self.cursor = "ibeam" + end + + if self.mouse_selecting then + local l1, c1 = self:resolve_screen_position(x, y) + local l2, c2 = table.unpack(self.mouse_selecting) + local clicks = self.mouse_selecting.clicks + self.doc:set_selection(mouse_selection(self.doc, clicks, l1, c1, l2, c2)) + end +end + + +function DocView:on_mouse_released(button) + DocView.super.on_mouse_released(self, button) + self.mouse_selecting = nil +end + + +function DocView:on_text_input(text) + self.doc:text_input(text) +end + + +function DocView:update() + -- scroll to make caret visible and reset blink timer if it moved + local line, col = self.doc:get_selection() + if (line ~= self.last_line or col ~= self.last_col) and self.size.x > 0 then + if core.active_view == self then + self:scroll_to_make_visible(line, col) + end + self.blink_timer = 0 + self.last_line, self.last_col = line, col + end + + -- update blink timer + if self == core.active_view and not self.mouse_selecting then + local n = blink_period / 2 + local prev = self.blink_timer + self.blink_timer = (self.blink_timer + 1 / config.fps) % blink_period + if (self.blink_timer > n) ~= (prev > n) then + core.redraw = true + end + end + + DocView.super.update(self) +end + + +function DocView:draw_line_highlight(x, y) + local lh = self:get_line_height() + renderer.draw_rect(x, y, self.size.x, lh, style.line_highlight) +end + + +function DocView:draw_line_text(idx, x, y) + local tx, ty = x, y + self:get_line_text_y_offset() + local font = self:get_font() + for _, type, text in self.doc.highlighter:each_token(idx) do + local color = style.syntax[type] + tx = renderer.draw_text(font, text, tx, ty, color) + end +end + + +function DocView:draw_line_body(idx, x, y) + local line, col = self.doc:get_selection() + + -- draw selection if it overlaps this line + local line1, col1, line2, col2 = self.doc:get_selection(true) + if idx >= line1 and idx <= line2 then + local text = self.doc.lines[idx] + if line1 ~= idx then col1 = 1 end + if line2 ~= idx then col2 = #text + 1 end + local x1 = x + self:get_col_x_offset(idx, col1) + local x2 = x + self:get_col_x_offset(idx, col2) + local lh = self:get_line_height() + renderer.draw_rect(x1, y, x2 - x1, lh, style.selection) + end + + -- draw line highlight if caret is on this line + if config.highlight_current_line and not self.doc:has_selection() + and line == idx and core.active_view == self then + self:draw_line_highlight(x + self.scroll.x, y) + end + + -- draw line's text + self:draw_line_text(idx, x, y) + + -- draw caret if it overlaps this line + if line == idx and core.active_view == self + and self.blink_timer < blink_period / 2 + and system.window_has_focus() then + local lh = self:get_line_height() + local x1 = x + self:get_col_x_offset(line, col) + renderer.draw_rect(x1, y, style.caret_width, lh, style.caret) + end +end + + +function DocView:draw_line_gutter(idx, x, y) + local color = style.line_number + local line1, _, line2, _ = self.doc:get_selection(true) + if idx >= line1 and idx <= line2 then + color = style.line_number2 + end + local yoffset = self:get_line_text_y_offset() + x = x + style.padding.x + renderer.draw_text(self:get_font(), idx, x, y + yoffset, color) +end + + +function DocView:draw() + self:draw_background(style.background) + + local font = self:get_font() + font:set_tab_width(font:get_width(" ") * config.indent_size) + + local minline, maxline = self:get_visible_line_range() + local lh = self:get_line_height() + + local _, y = self:get_line_screen_position(minline) + local x = self.position.x + for i = minline, maxline do + self:draw_line_gutter(i, x, y) + y = y + lh + end + + local x, y = self:get_line_screen_position(minline) + local gw = self:get_gutter_width() + local pos = self.position + core.push_clip_rect(pos.x + gw, pos.y, self.size.x, self.size.y) + for i = minline, maxline do + self:draw_line_body(i, x, y) + y = y + lh + end + core.pop_clip_rect() + + self:draw_scrollbar() +end + + +return DocView diff --git a/tools/lite/data/core/init.lua b/tools/lite/data/core/init.lua new file mode 100644 index 0000000..cef305e --- /dev/null +++ b/tools/lite/data/core/init.lua @@ -0,0 +1,480 @@ +require "core.strict" +local common = require "core.common" +local config = require "core.config" +local style = require "core.style" +local command +local keymap +local RootView +local StatusView +local CommandView +local Doc + +local core = {} + + +local function project_scan_thread() + local function diff_files(a, b) + if #a ~= #b then return true end + for i, v in ipairs(a) do + if b[i].filename ~= v.filename + or b[i].modified ~= v.modified then + return true + end + end + end + + local function compare_file(a, b) + return a.filename < b.filename + end + + local function get_files(path, t) + coroutine.yield() + t = t or {} + local size_limit = config.file_size_limit * 10e5 + local all = system.list_dir(path) or {} + local dirs, files = {}, {} + + for _, file in ipairs(all) do + if not common.match_pattern(file, config.ignore_files) then + local file = (path ~= "." and path .. PATHSEP or "") .. file + local info = system.get_file_info(file) + if info and info.size < size_limit then + info.filename = file + table.insert(info.type == "dir" and dirs or files, info) + end + end + end + + table.sort(dirs, compare_file) + for _, f in ipairs(dirs) do + table.insert(t, f) + get_files(f.filename, t) + end + + table.sort(files, compare_file) + for _, f in ipairs(files) do + table.insert(t, f) + end + + return t + end + + while true do + -- get project files and replace previous table if the new table is + -- different + local t = get_files(".") + if diff_files(core.project_files, t) then + core.project_files = t + core.redraw = true + end + + -- wait for next scan + coroutine.yield(config.project_scan_rate) + end +end + + +function core.init() + command = require "core.command" + keymap = require "core.keymap" + RootView = require "core.rootview" + StatusView = require "core.statusview" + CommandView = require "core.commandview" + Doc = require "core.doc" + + local project_dir = EXEDIR + local files = {} + for i = 2, #ARGS do + local info = system.get_file_info(ARGS[i]) or {} + if info.type == "file" then + table.insert(files, system.absolute_path(ARGS[i])) + elseif info.type == "dir" then + project_dir = ARGS[i] + end + end + + system.chdir(project_dir) + + core.frame_start = 0 + core.clip_rect_stack = {{ 0,0,0,0 }} + core.log_items = {} + core.docs = {} + core.threads = setmetatable({}, { __mode = "k" }) + core.project_files = {} + core.redraw = true + + core.root_view = RootView() + core.command_view = CommandView() + core.status_view = StatusView() + + core.root_view.root_node:split("down", core.command_view, true) + core.root_view.root_node.b:split("down", core.status_view, true) + + core.add_thread(project_scan_thread) + command.add_defaults() + local got_plugin_error = not core.load_plugins() + local got_user_error = not core.try(require, "user") + local got_project_error = not core.load_project_module() + + for _, filename in ipairs(files) do + core.root_view:open_doc(core.open_doc(filename)) + end + + if got_plugin_error or got_user_error or got_project_error then + command.perform("core:open-log") + end +end + + +local temp_uid = (system.get_time() * 1000) % 0xffffffff +local temp_file_prefix = string.format(".lite_temp_%08x", temp_uid) +local temp_file_counter = 0 + +local function delete_temp_files() + for _, filename in ipairs(system.list_dir(EXEDIR)) do + if filename:find(temp_file_prefix, 1, true) == 1 then + os.remove(EXEDIR .. PATHSEP .. filename) + end + end +end + +function core.temp_filename(ext) + temp_file_counter = temp_file_counter + 1 + return EXEDIR .. PATHSEP .. temp_file_prefix + .. string.format("%06x", temp_file_counter) .. (ext or "") +end + + +function core.quit(force) + if force then + delete_temp_files() + os.exit() + end + local dirty_count = 0 + local dirty_name + for _, doc in ipairs(core.docs) do + if doc:is_dirty() then + dirty_count = dirty_count + 1 + dirty_name = doc:get_name() + end + end + if dirty_count > 0 then + local text + if dirty_count == 1 then + text = string.format("\"%s\" has unsaved changes. Quit anyway?", dirty_name) + else + text = string.format("%d docs have unsaved changes. Quit anyway?", dirty_count) + end + local confirm = system.show_confirm_dialog("Unsaved Changes", text) + if not confirm then return end + end + core.quit(true) +end + + +function core.load_plugins() + local no_errors = true + local files = system.list_dir(EXEDIR .. "/data/plugins") + for _, filename in ipairs(files) do + local modname = "plugins." .. filename:gsub(".lua$", "") + local ok = core.try(require, modname) + if ok then + core.log_quiet("Loaded plugin %q", modname) + else + no_errors = false + end + end + return no_errors +end + + +function core.load_project_module() + local filename = ".lite_project.lua" + if system.get_file_info(filename) then + return core.try(function() + local fn, err = loadfile(filename) + if not fn then error("Error when loading project module:\n\t" .. err) end + fn() + core.log_quiet("Loaded project module") + end) + end + return true +end + + +function core.reload_module(name) + local old = package.loaded[name] + package.loaded[name] = nil + local new = require(name) + if type(old) == "table" then + for k, v in pairs(new) do old[k] = v end + package.loaded[name] = old + end +end + + +function core.set_active_view(view) + assert(view, "Tried to set active view to nil") + if view ~= core.active_view then + core.last_active_view = core.active_view + core.active_view = view + end +end + + +function core.add_thread(f, weak_ref) + local key = weak_ref or #core.threads + 1 + local fn = function() return core.try(f) end + core.threads[key] = { cr = coroutine.create(fn), wake = 0 } +end + + +function core.push_clip_rect(x, y, w, h) + local x2, y2, w2, h2 = table.unpack(core.clip_rect_stack[#core.clip_rect_stack]) + local r, b, r2, b2 = x+w, y+h, x2+w2, y2+h2 + x, y = math.max(x, x2), math.max(y, y2) + b, r = math.min(b, b2), math.min(r, r2) + w, h = r-x, b-y + table.insert(core.clip_rect_stack, { x, y, w, h }) + renderer.set_clip_rect(x, y, w, h) +end + + +function core.pop_clip_rect() + table.remove(core.clip_rect_stack) + local x, y, w, h = table.unpack(core.clip_rect_stack[#core.clip_rect_stack]) + renderer.set_clip_rect(x, y, w, h) +end + + +function core.open_doc(filename) + if filename then + -- try to find existing doc for filename + local abs_filename = system.absolute_path(filename) + for _, doc in ipairs(core.docs) do + if doc.filename + and abs_filename == system.absolute_path(doc.filename) then + return doc + end + end + end + -- no existing doc for filename; create new + local doc = Doc(filename) + table.insert(core.docs, doc) + core.log_quiet(filename and "Opened doc \"%s\"" or "Opened new doc", filename) + return doc +end + + +function core.get_views_referencing_doc(doc) + local res = {} + local views = core.root_view.root_node:get_children() + for _, view in ipairs(views) do + if view.doc == doc then table.insert(res, view) end + end + return res +end + + +local function log(icon, icon_color, fmt, ...) + local text = string.format(fmt, ...) + if icon then + core.status_view:show_message(icon, icon_color, text) + end + + local info = debug.getinfo(2, "Sl") + local at = string.format("%s:%d", info.short_src, info.currentline) + local item = { text = text, time = os.time(), at = at } + table.insert(core.log_items, item) + if #core.log_items > config.max_log_items then + table.remove(core.log_items, 1) + end + return item +end + + +function core.log(...) + return log("i", style.text, ...) +end + + +function core.log_quiet(...) + return log(nil, nil, ...) +end + + +function core.error(...) + return log("!", style.accent, ...) +end + + +function core.try(fn, ...) + local err + local ok, res = xpcall(fn, function(msg) + local item = core.error("%s", msg) + item.info = debug.traceback(nil, 2):gsub("\t", "") + err = msg + end, ...) + if ok then + return true, res + end + return false, err +end + + +function core.on_event(type, ...) + local did_keymap = false + if type == "textinput" then + core.root_view:on_text_input(...) + elseif type == "keypressed" then + did_keymap = keymap.on_key_pressed(...) + elseif type == "keyreleased" then + keymap.on_key_released(...) + elseif type == "mousemoved" then + core.root_view:on_mouse_moved(...) + elseif type == "mousepressed" then + core.root_view:on_mouse_pressed(...) + elseif type == "mousereleased" then + core.root_view:on_mouse_released(...) + elseif type == "mousewheel" then + core.root_view:on_mouse_wheel(...) + elseif type == "filedropped" then + local filename, mx, my = ... + local info = system.get_file_info(filename) + if info and info.type == "dir" then + system.exec(string.format("%q %q", EXEFILE, filename)) + else + local ok, doc = core.try(core.open_doc, filename) + if ok then + local node = core.root_view.root_node:get_child_overlapping_point(mx, my) + node:set_active_view(node.active_view) + core.root_view:open_doc(doc) + end + end + elseif type == "quit" then + core.quit() + end + return did_keymap +end + + +function core.step() + -- handle events + local did_keymap = false + local mouse_moved = false + local mouse = { x = 0, y = 0, dx = 0, dy = 0 } + + for type, a,b,c,d in system.poll_event do + if type == "mousemoved" then + mouse_moved = true + mouse.x, mouse.y = a, b + mouse.dx, mouse.dy = mouse.dx + c, mouse.dy + d + elseif type == "textinput" and did_keymap then + did_keymap = false + else + local _, res = core.try(core.on_event, type, a, b, c, d) + did_keymap = res or did_keymap + end + core.redraw = true + end + if mouse_moved then + core.try(core.on_event, "mousemoved", mouse.x, mouse.y, mouse.dx, mouse.dy) + end + + local width, height = renderer.get_size() + + -- update + core.root_view.size.x, core.root_view.size.y = width, height + core.root_view:update() + if not core.redraw then return false end + core.redraw = false + + -- close unreferenced docs + for i = #core.docs, 1, -1 do + local doc = core.docs[i] + if #core.get_views_referencing_doc(doc) == 0 then + table.remove(core.docs, i) + core.log_quiet("Closed doc \"%s\"", doc:get_name()) + end + end + + -- update window title + local name = core.active_view:get_name() + local title = (name ~= "---") and (name .. " - lite") or "lite" + if title ~= core.window_title then + system.set_window_title(title) + core.window_title = title + end + + -- draw + renderer.begin_frame() + core.clip_rect_stack[1] = { 0, 0, width, height } + renderer.set_clip_rect(table.unpack(core.clip_rect_stack[1])) + core.root_view:draw() + renderer.end_frame() + return true +end + + +local run_threads = coroutine.wrap(function() + while true do + local max_time = 1 / config.fps - 0.004 + local ran_any_threads = false + + for k, thread in pairs(core.threads) do + -- run thread + if thread.wake < system.get_time() then + local _, wait = assert(coroutine.resume(thread.cr)) + if coroutine.status(thread.cr) == "dead" then + if type(k) == "number" then + table.remove(core.threads, k) + else + core.threads[k] = nil + end + elseif wait then + thread.wake = system.get_time() + wait + end + ran_any_threads = true + end + + -- stop running threads if we're about to hit the end of frame + if system.get_time() - core.frame_start > max_time then + coroutine.yield() + end + end + + if not ran_any_threads then coroutine.yield() end + end +end) + + +function core.run() + while true do + core.frame_start = system.get_time() + local did_redraw = core.step() + run_threads() + if not did_redraw and not system.window_has_focus() then + system.wait_event(0.25) + end + local elapsed = system.get_time() - core.frame_start + system.sleep(math.max(0, 1 / config.fps - elapsed)) + end +end + + +function core.on_error(err) + -- write error to file + local fp = io.open(EXEDIR .. "/error.txt", "wb") + fp:write("Error: " .. tostring(err) .. "\n") + fp:write(debug.traceback(nil, 4)) + fp:close() + -- save copy of all unsaved documents + for _, doc in ipairs(core.docs) do + if doc:is_dirty() and doc.filename then + doc:save(doc.filename .. "~") + end + end +end + + +return core diff --git a/tools/lite/data/core/keymap.lua b/tools/lite/data/core/keymap.lua new file mode 100644 index 0000000..4d48ed4 --- /dev/null +++ b/tools/lite/data/core/keymap.lua @@ -0,0 +1,186 @@ +local command = require "core.command" +local keymap = {} + +keymap.modkeys = {} +keymap.map = {} +keymap.reverse_map = {} + +local modkey_map = { + ["left ctrl"] = "ctrl", + ["right ctrl"] = "ctrl", + ["left shift"] = "shift", + ["right shift"] = "shift", + ["left alt"] = "alt", + ["right alt"] = "altgr", +} + +local modkeys = { "ctrl", "alt", "altgr", "shift" } + +local function key_to_stroke(k) + local stroke = "" + for _, mk in ipairs(modkeys) do + if keymap.modkeys[mk] then + stroke = stroke .. mk .. "+" + end + end + return stroke .. k +end + + +function keymap.add(map, overwrite) + for stroke, commands in pairs(map) do + if type(commands) == "string" then + commands = { commands } + end + if overwrite then + keymap.map[stroke] = commands + else + keymap.map[stroke] = keymap.map[stroke] or {} + for i = #commands, 1, -1 do + table.insert(keymap.map[stroke], 1, commands[i]) + end + end + for _, cmd in ipairs(commands) do + keymap.reverse_map[cmd] = stroke + end + end +end + + +function keymap.get_binding(cmd) + return keymap.reverse_map[cmd] +end + + +function keymap.on_key_pressed(k) + local mk = modkey_map[k] + if mk then + keymap.modkeys[mk] = true + -- work-around for windows where `altgr` is treated as `ctrl+alt` + if mk == "altgr" then + keymap.modkeys["ctrl"] = false + end + else + local stroke = key_to_stroke(k) + local commands = keymap.map[stroke] + if commands then + for _, cmd in ipairs(commands) do + local performed = command.perform(cmd) + if performed then break end + end + return true + end + end + return false +end + + +function keymap.on_key_released(k) + local mk = modkey_map[k] + if mk then + keymap.modkeys[mk] = false + end +end + + +keymap.add { + ["ctrl+shift+p"] = "core:find-command", + ["ctrl+p"] = "core:find-file", + ["ctrl+o"] = "core:open-file", + ["ctrl+n"] = "core:new-doc", + ["alt+return"] = "core:toggle-fullscreen", + + ["alt+shift+j"] = "root:split-left", + ["alt+shift+l"] = "root:split-right", + ["alt+shift+i"] = "root:split-up", + ["alt+shift+k"] = "root:split-down", + ["alt+j"] = "root:switch-to-left", + ["alt+l"] = "root:switch-to-right", + ["alt+i"] = "root:switch-to-up", + ["alt+k"] = "root:switch-to-down", + + ["ctrl+w"] = "root:close", + ["ctrl+tab"] = "root:switch-to-next-tab", + ["ctrl+shift+tab"] = "root:switch-to-previous-tab", + ["ctrl+pageup"] = "root:move-tab-left", + ["ctrl+pagedown"] = "root:move-tab-right", + ["alt+1"] = "root:switch-to-tab-1", + ["alt+2"] = "root:switch-to-tab-2", + ["alt+3"] = "root:switch-to-tab-3", + ["alt+4"] = "root:switch-to-tab-4", + ["alt+5"] = "root:switch-to-tab-5", + ["alt+6"] = "root:switch-to-tab-6", + ["alt+7"] = "root:switch-to-tab-7", + ["alt+8"] = "root:switch-to-tab-8", + ["alt+9"] = "root:switch-to-tab-9", + + ["ctrl+f"] = "find-replace:find", + ["ctrl+r"] = "find-replace:replace", + ["f3"] = "find-replace:repeat-find", + ["shift+f3"] = "find-replace:previous-find", + ["ctrl+g"] = "doc:go-to-line", + ["ctrl+s"] = "doc:save", + ["ctrl+shift+s"] = "doc:save-as", + + ["ctrl+z"] = "doc:undo", + ["ctrl+y"] = "doc:redo", + ["ctrl+x"] = "doc:cut", + ["ctrl+c"] = "doc:copy", + ["ctrl+v"] = "doc:paste", + ["escape"] = { "command:escape", "doc:select-none" }, + ["tab"] = { "command:complete", "doc:indent" }, + ["shift+tab"] = "doc:unindent", + ["backspace"] = "doc:backspace", + ["shift+backspace"] = "doc:backspace", + ["ctrl+backspace"] = "doc:delete-to-previous-word-start", + ["ctrl+shift+backspace"] = "doc:delete-to-previous-word-start", + ["delete"] = "doc:delete", + ["shift+delete"] = "doc:delete", + ["ctrl+delete"] = "doc:delete-to-next-word-end", + ["ctrl+shift+delete"] = "doc:delete-to-next-word-end", + ["return"] = { "command:submit", "doc:newline" }, + ["keypad enter"] = { "command:submit", "doc:newline" }, + ["ctrl+return"] = "doc:newline-below", + ["ctrl+shift+return"] = "doc:newline-above", + ["ctrl+j"] = "doc:join-lines", + ["ctrl+a"] = "doc:select-all", + ["ctrl+d"] = { "find-replace:select-next", "doc:select-word" }, + ["ctrl+l"] = "doc:select-lines", + ["ctrl+/"] = "doc:toggle-line-comments", + ["ctrl+up"] = "doc:move-lines-up", + ["ctrl+down"] = "doc:move-lines-down", + ["ctrl+shift+d"] = "doc:duplicate-lines", + ["ctrl+shift+k"] = "doc:delete-lines", + + ["left"] = "doc:move-to-previous-char", + ["right"] = "doc:move-to-next-char", + ["up"] = { "command:select-previous", "doc:move-to-previous-line" }, + ["down"] = { "command:select-next", "doc:move-to-next-line" }, + ["ctrl+left"] = "doc:move-to-previous-word-start", + ["ctrl+right"] = "doc:move-to-next-word-end", + ["ctrl+["] = "doc:move-to-previous-block-start", + ["ctrl+]"] = "doc:move-to-next-block-end", + ["home"] = "doc:move-to-start-of-line", + ["end"] = "doc:move-to-end-of-line", + ["ctrl+home"] = "doc:move-to-start-of-doc", + ["ctrl+end"] = "doc:move-to-end-of-doc", + ["pageup"] = "doc:move-to-previous-page", + ["pagedown"] = "doc:move-to-next-page", + + ["shift+left"] = "doc:select-to-previous-char", + ["shift+right"] = "doc:select-to-next-char", + ["shift+up"] = "doc:select-to-previous-line", + ["shift+down"] = "doc:select-to-next-line", + ["ctrl+shift+left"] = "doc:select-to-previous-word-start", + ["ctrl+shift+right"] = "doc:select-to-next-word-end", + ["ctrl+shift+["] = "doc:select-to-previous-block-start", + ["ctrl+shift+]"] = "doc:select-to-next-block-end", + ["shift+home"] = "doc:select-to-start-of-line", + ["shift+end"] = "doc:select-to-end-of-line", + ["ctrl+shift+home"] = "doc:select-to-start-of-doc", + ["ctrl+shift+end"] = "doc:select-to-end-of-doc", + ["shift+pageup"] = "doc:select-to-previous-page", + ["shift+pagedown"] = "doc:select-to-next-page", +} + +return keymap diff --git a/tools/lite/data/core/logview.lua b/tools/lite/data/core/logview.lua new file mode 100644 index 0000000..d7142fb --- /dev/null +++ b/tools/lite/data/core/logview.lua @@ -0,0 +1,74 @@ +local core = require "core" +local style = require "core.style" +local View = require "core.view" + + +local LogView = View:extend() + + +function LogView:new() + LogView.super.new(self) + self.last_item = core.log_items[#core.log_items] + self.scrollable = true + self.yoffset = 0 +end + + +function LogView:get_name() + return "Log" +end + + +function LogView:update() + local item = core.log_items[#core.log_items] + if self.last_item ~= item then + self.last_item = item + self.scroll.to.y = 0 + self.yoffset = -(style.font:get_height() + style.padding.y) + end + + self:move_towards("yoffset", 0) + + LogView.super.update(self) +end + + +local function draw_text_multiline(font, text, x, y, color) + local th = font:get_height() + local resx, resy = x, y + for line in text:gmatch("[^\n]+") do + resy = y + resx = renderer.draw_text(style.font, line, x, y, color) + y = y + th + end + return resx, resy +end + + +function LogView:draw() + self:draw_background(style.background) + + local ox, oy = self:get_content_offset() + local th = style.font:get_height() + local y = oy + style.padding.y + self.yoffset + + for i = #core.log_items, 1, -1 do + local x = ox + style.padding.x + local item = core.log_items[i] + local time = os.date(nil, item.time) + x = renderer.draw_text(style.font, time, x, y, style.dim) + x = x + style.padding.x + local subx = x + x, y = draw_text_multiline(style.font, item.text, x, y, style.text) + renderer.draw_text(style.font, " at " .. item.at, x, y, style.dim) + y = y + th + if item.info then + subx, y = draw_text_multiline(style.font, item.info, subx, y, style.dim) + y = y + th + end + y = y + style.padding.y + end +end + + +return LogView diff --git a/tools/lite/data/core/object.lua b/tools/lite/data/core/object.lua new file mode 100644 index 0000000..af41b7e --- /dev/null +++ b/tools/lite/data/core/object.lua @@ -0,0 +1,58 @@ +local Object = {} +Object.__index = Object + + +function Object:new() +end + + +function Object:extend() + local cls = {} + for k, v in pairs(self) do + if k:find("__") == 1 then + cls[k] = v + end + end + cls.__index = cls + cls.super = self + setmetatable(cls, self) + return cls +end + + +function Object:implement(...) + for _, cls in pairs({...}) do + for k, v in pairs(cls) do + if self[k] == nil and type(v) == "function" then + self[k] = v + end + end + end +end + + +function Object:is(T) + local mt = getmetatable(self) + while mt do + if mt == T then + return true + end + mt = getmetatable(mt) + end + return false +end + + +function Object:__tostring() + return "Object" +end + + +function Object:__call(...) + local obj = setmetatable({}, self) + obj:new(...) + return obj +end + + +return Object diff --git a/tools/lite/data/core/rootview.lua b/tools/lite/data/core/rootview.lua new file mode 100644 index 0000000..389525f --- /dev/null +++ b/tools/lite/data/core/rootview.lua @@ -0,0 +1,504 @@ +local core = require "core" +local common = require "core.common" +local style = require "core.style" +local keymap = require "core.keymap" +local Object = require "core.object" +local View = require "core.view" +local DocView = require "core.docview" + + +local EmptyView = View:extend() + +local function draw_text(x, y, color) + local th = style.big_font:get_height() + local dh = th + style.padding.y * 2 + x = renderer.draw_text(style.big_font, "lite", x, y + (dh - th) / 2, color) + x = x + style.padding.x + renderer.draw_rect(x, y, math.ceil(1 * SCALE), dh, color) + local lines = { + { fmt = "%s to run a command", cmd = "core:find-command" }, + { fmt = "%s to open a file from the project", cmd = "core:find-file" }, + } + th = style.font:get_height() + y = y + (dh - th * 2 - style.padding.y) / 2 + local w = 0 + for _, line in ipairs(lines) do + local text = string.format(line.fmt, keymap.get_binding(line.cmd)) + w = math.max(w, renderer.draw_text(style.font, text, x + style.padding.x, y, color)) + y = y + th + style.padding.y + end + return w, dh +end + +function EmptyView:draw() + self:draw_background(style.background) + local w, h = draw_text(0, 0, { 0, 0, 0, 0 }) + local x = self.position.x + math.max(style.padding.x, (self.size.x - w) / 2) + local y = self.position.y + (self.size.y - h) / 2 + draw_text(x, y, style.dim) +end + + + +local Node = Object:extend() + +function Node:new(type) + self.type = type or "leaf" + self.position = { x = 0, y = 0 } + self.size = { x = 0, y = 0 } + self.views = {} + self.divider = 0.5 + if self.type == "leaf" then + self:add_view(EmptyView()) + end +end + + +function Node:propagate(fn, ...) + self.a[fn](self.a, ...) + self.b[fn](self.b, ...) +end + + +function Node:on_mouse_moved(x, y, ...) + self.hovered_tab = self:get_tab_overlapping_point(x, y) + if self.type == "leaf" then + self.active_view:on_mouse_moved(x, y, ...) + else + self:propagate("on_mouse_moved", x, y, ...) + end +end + + +function Node:on_mouse_released(...) + if self.type == "leaf" then + self.active_view:on_mouse_released(...) + else + self:propagate("on_mouse_released", ...) + end +end + + +function Node:consume(node) + for k, _ in pairs(self) do self[k] = nil end + for k, v in pairs(node) do self[k] = v end +end + + +local type_map = { up="vsplit", down="vsplit", left="hsplit", right="hsplit" } + +function Node:split(dir, view, locked) + assert(self.type == "leaf", "Tried to split non-leaf node") + local type = assert(type_map[dir], "Invalid direction") + local last_active = core.active_view + local child = Node() + child:consume(self) + self:consume(Node(type)) + self.a = child + self.b = Node() + if view then self.b:add_view(view) end + if locked then + self.b.locked = locked + core.set_active_view(last_active) + end + if dir == "up" or dir == "left" then + self.a, self.b = self.b, self.a + end + return child +end + + +function Node:close_active_view(root) + local do_close = function() + if #self.views > 1 then + local idx = self:get_view_idx(self.active_view) + table.remove(self.views, idx) + self:set_active_view(self.views[idx] or self.views[#self.views]) + else + local parent = self:get_parent_node(root) + local is_a = (parent.a == self) + local other = parent[is_a and "b" or "a"] + if other:get_locked_size() then + self.views = {} + self:add_view(EmptyView()) + else + parent:consume(other) + local p = parent + while p.type ~= "leaf" do + p = p[is_a and "a" or "b"] + end + p:set_active_view(p.active_view) + end + end + core.last_active_view = nil + end + self.active_view:try_close(do_close) +end + + +function Node:add_view(view) + assert(self.type == "leaf", "Tried to add view to non-leaf node") + assert(not self.locked, "Tried to add view to locked node") + if self.views[1] and self.views[1]:is(EmptyView) then + table.remove(self.views) + end + table.insert(self.views, view) + self:set_active_view(view) +end + + +function Node:set_active_view(view) + assert(self.type == "leaf", "Tried to set active view on non-leaf node") + self.active_view = view + core.set_active_view(view) +end + + +function Node:get_view_idx(view) + for i, v in ipairs(self.views) do + if v == view then return i end + end +end + + +function Node:get_node_for_view(view) + for _, v in ipairs(self.views) do + if v == view then return self end + end + if self.type ~= "leaf" then + return self.a:get_node_for_view(view) or self.b:get_node_for_view(view) + end +end + + +function Node:get_parent_node(root) + if root.a == self or root.b == self then + return root + elseif root.type ~= "leaf" then + return self:get_parent_node(root.a) or self:get_parent_node(root.b) + end +end + + +function Node:get_children(t) + t = t or {} + for _, view in ipairs(self.views) do + table.insert(t, view) + end + if self.a then self.a:get_children(t) end + if self.b then self.b:get_children(t) end + return t +end + + +function Node:get_divider_overlapping_point(px, py) + if self.type ~= "leaf" then + local p = 6 + local x, y, w, h = self:get_divider_rect() + x, y = x - p, y - p + w, h = w + p * 2, h + p * 2 + if px > x and py > y and px < x + w and py < y + h then + return self + end + return self.a:get_divider_overlapping_point(px, py) + or self.b:get_divider_overlapping_point(px, py) + end +end + + +function Node:get_tab_overlapping_point(px, py) + if #self.views == 1 then return nil end + local x, y, w, h = self:get_tab_rect(1) + if px >= x and py >= y and px < x + w * #self.views and py < y + h then + return math.floor((px - x) / w) + 1 + end +end + + +function Node:get_child_overlapping_point(x, y) + local child + if self.type == "leaf" then + return self + elseif self.type == "hsplit" then + child = (x < self.b.position.x) and self.a or self.b + elseif self.type == "vsplit" then + child = (y < self.b.position.y) and self.a or self.b + end + return child:get_child_overlapping_point(x, y) +end + + +function Node:get_tab_rect(idx) + local tw = math.min(style.tab_width, math.ceil(self.size.x / #self.views)) + local h = style.font:get_height() + style.padding.y * 2 + return self.position.x + (idx-1) * tw, self.position.y, tw, h +end + + +function Node:get_divider_rect() + local x, y = self.position.x, self.position.y + if self.type == "hsplit" then + return x + self.a.size.x, y, style.divider_size, self.size.y + elseif self.type == "vsplit" then + return x, y + self.a.size.y, self.size.x, style.divider_size + end +end + + +function Node:get_locked_size() + if self.type == "leaf" then + if self.locked then + local size = self.active_view.size + return size.x, size.y + end + else + local x1, y1 = self.a:get_locked_size() + local x2, y2 = self.b:get_locked_size() + if x1 and x2 then + local dsx = (x1 < 1 or x2 < 1) and 0 or style.divider_size + local dsy = (y1 < 1 or y2 < 1) and 0 or style.divider_size + return x1 + x2 + dsx, y1 + y2 + dsy + end + end +end + + +local function copy_position_and_size(dst, src) + dst.position.x, dst.position.y = src.position.x, src.position.y + dst.size.x, dst.size.y = src.size.x, src.size.y +end + + +-- calculating the sizes is the same for hsplits and vsplits, except the x/y +-- axis are swapped; this function lets us use the same code for both +local function calc_split_sizes(self, x, y, x1, x2) + local n + local ds = (x1 and x1 < 1 or x2 and x2 < 1) and 0 or style.divider_size + if x1 then + n = x1 + ds + elseif x2 then + n = self.size[x] - x2 + else + n = math.floor(self.size[x] * self.divider) + end + self.a.position[x] = self.position[x] + self.a.position[y] = self.position[y] + self.a.size[x] = n - ds + self.a.size[y] = self.size[y] + self.b.position[x] = self.position[x] + n + self.b.position[y] = self.position[y] + self.b.size[x] = self.size[x] - n + self.b.size[y] = self.size[y] +end + + +function Node:update_layout() + if self.type == "leaf" then + local av = self.active_view + if #self.views > 1 then + local _, _, _, th = self:get_tab_rect(1) + av.position.x, av.position.y = self.position.x, self.position.y + th + av.size.x, av.size.y = self.size.x, self.size.y - th + else + copy_position_and_size(av, self) + end + else + local x1, y1 = self.a:get_locked_size() + local x2, y2 = self.b:get_locked_size() + if self.type == "hsplit" then + calc_split_sizes(self, "x", "y", x1, x2) + elseif self.type == "vsplit" then + calc_split_sizes(self, "y", "x", y1, y2) + end + self.a:update_layout() + self.b:update_layout() + end +end + + +function Node:update() + if self.type == "leaf" then + for _, view in ipairs(self.views) do + view:update() + end + else + self.a:update() + self.b:update() + end +end + + +function Node:draw_tabs() + local x, y, _, h = self:get_tab_rect(1) + local ds = style.divider_size + core.push_clip_rect(x, y, self.size.x, h) + renderer.draw_rect(x, y, self.size.x, h, style.background2) + renderer.draw_rect(x, y + h - ds, self.size.x, ds, style.divider) + + for i, view in ipairs(self.views) do + local x, y, w, h = self:get_tab_rect(i) + local text = view:get_name() + local color = style.dim + if view == self.active_view then + color = style.text + renderer.draw_rect(x, y, w, h, style.background) + renderer.draw_rect(x + w, y, ds, h, style.divider) + renderer.draw_rect(x - ds, y, ds, h, style.divider) + end + if i == self.hovered_tab then + color = style.text + end + core.push_clip_rect(x, y, w, h) + x, w = x + style.padding.x, w - style.padding.x * 2 + local align = style.font:get_width(text) > w and "left" or "center" + common.draw_text(style.font, color, text, align, x, y, w, h) + core.pop_clip_rect() + end + + core.pop_clip_rect() +end + + +function Node:draw() + if self.type == "leaf" then + if #self.views > 1 then + self:draw_tabs() + end + local pos, size = self.active_view.position, self.active_view.size + core.push_clip_rect(pos.x, pos.y, size.x + pos.x % 1, size.y + pos.y % 1) + self.active_view:draw() + core.pop_clip_rect() + else + local x, y, w, h = self:get_divider_rect() + renderer.draw_rect(x, y, w, h, style.divider) + self:propagate("draw") + end +end + + + +local RootView = View:extend() + +function RootView:new() + RootView.super.new(self) + self.root_node = Node() + self.deferred_draws = {} + self.mouse = { x = 0, y = 0 } +end + + +function RootView:defer_draw(fn, ...) + table.insert(self.deferred_draws, 1, { fn = fn, ... }) +end + + +function RootView:get_active_node() + return self.root_node:get_node_for_view(core.active_view) +end + + +function RootView:open_doc(doc) + local node = self:get_active_node() + if node.locked and core.last_active_view then + core.set_active_view(core.last_active_view) + node = self:get_active_node() + end + assert(not node.locked, "Cannot open doc on locked node") + for i, view in ipairs(node.views) do + if view.doc == doc then + node:set_active_view(node.views[i]) + return view + end + end + local view = DocView(doc) + node:add_view(view) + self.root_node:update_layout() + view:scroll_to_line(view.doc:get_selection(), true, true) + return view +end + + +function RootView:on_mouse_pressed(button, x, y, clicks) + local div = self.root_node:get_divider_overlapping_point(x, y) + if div then + self.dragged_divider = div + return + end + local node = self.root_node:get_child_overlapping_point(x, y) + local idx = node:get_tab_overlapping_point(x, y) + if idx then + node:set_active_view(node.views[idx]) + if button == "middle" then + node:close_active_view(self.root_node) + end + else + core.set_active_view(node.active_view) + node.active_view:on_mouse_pressed(button, x, y, clicks) + end +end + + +function RootView:on_mouse_released(...) + if self.dragged_divider then + self.dragged_divider = nil + end + self.root_node:on_mouse_released(...) +end + + +function RootView:on_mouse_moved(x, y, dx, dy) + if self.dragged_divider then + local node = self.dragged_divider + if node.type == "hsplit" then + node.divider = node.divider + dx / node.size.x + else + node.divider = node.divider + dy / node.size.y + end + node.divider = common.clamp(node.divider, 0.01, 0.99) + return + end + + self.mouse.x, self.mouse.y = x, y + self.root_node:on_mouse_moved(x, y, dx, dy) + + local node = self.root_node:get_child_overlapping_point(x, y) + local div = self.root_node:get_divider_overlapping_point(x, y) + if div then + system.set_cursor(div.type == "hsplit" and "sizeh" or "sizev") + elseif node:get_tab_overlapping_point(x, y) then + system.set_cursor("arrow") + else + system.set_cursor(node.active_view.cursor) + end +end + + +function RootView:on_mouse_wheel(...) + local x, y = self.mouse.x, self.mouse.y + local node = self.root_node:get_child_overlapping_point(x, y) + node.active_view:on_mouse_wheel(...) +end + + +function RootView:on_text_input(...) + core.active_view:on_text_input(...) +end + + +function RootView:update() + copy_position_and_size(self.root_node, self) + self.root_node:update() + self.root_node:update_layout() +end + + +function RootView:draw() + self.root_node:draw() + while #self.deferred_draws > 0 do + local t = table.remove(self.deferred_draws) + t.fn(table.unpack(t)) + end +end + + +return RootView diff --git a/tools/lite/data/core/statusview.lua b/tools/lite/data/core/statusview.lua new file mode 100644 index 0000000..67a4c8b --- /dev/null +++ b/tools/lite/data/core/statusview.lua @@ -0,0 +1,141 @@ +local core = require "core" +local common = require "core.common" +local command = require "core.command" +local config = require "core.config" +local style = require "core.style" +local DocView = require "core.docview" +local LogView = require "core.logview" +local View = require "core.view" + + +local StatusView = View:extend() + +StatusView.separator = " " +StatusView.separator2 = " | " + + +function StatusView:new() + StatusView.super.new(self) + self.message_timeout = 0 + self.message = {} +end + + +function StatusView:on_mouse_pressed() + core.set_active_view(core.last_active_view) + if system.get_time() < self.message_timeout + and not core.active_view:is(LogView) then + command.perform "core:open-log" + end +end + + +function StatusView:show_message(icon, icon_color, text) + self.message = { + icon_color, style.icon_font, icon, + style.dim, style.font, StatusView.separator2, style.text, text + } + self.message_timeout = system.get_time() + config.message_timeout +end + + +function StatusView:update() + self.size.y = style.font:get_height() + style.padding.y * 2 + + if system.get_time() < self.message_timeout then + self.scroll.to.y = self.size.y + else + self.scroll.to.y = 0 + end + + StatusView.super.update(self) +end + + +local function draw_items(self, items, x, y, draw_fn) + local font = style.font + local color = style.text + + for _, item in ipairs(items) do + if type(item) == "userdata" then + font = item + elseif type(item) == "table" then + color = item + else + x = draw_fn(font, color, item, nil, x, y, 0, self.size.y) + end + end + + return x +end + + +local function text_width(font, _, text, _, x) + return x + font:get_width(text) +end + + +function StatusView:draw_items(items, right_align, yoffset) + local x, y = self:get_content_offset() + y = y + (yoffset or 0) + if right_align then + local w = draw_items(self, items, 0, 0, text_width) + x = x + self.size.x - w - style.padding.x + draw_items(self, items, x, y, common.draw_text) + else + x = x + style.padding.x + draw_items(self, items, x, y, common.draw_text) + end +end + + +function StatusView:get_items() + if getmetatable(core.active_view) == DocView then + local dv = core.active_view + local line, col = dv.doc:get_selection() + local dirty = dv.doc:is_dirty() + + return { + dirty and style.accent or style.text, style.icon_font, "f", + style.dim, style.font, self.separator2, style.text, + dv.doc.filename and style.text or style.dim, dv.doc:get_name(), + style.text, + self.separator, + "line: ", line, + self.separator, + col > config.line_limit and style.accent or style.text, "col: ", col, + style.text, + self.separator, + string.format("%d%%", line / #dv.doc.lines * 100), + }, { + style.icon_font, "g", + style.font, style.dim, self.separator2, style.text, + #dv.doc.lines, " lines", + self.separator, + dv.doc.crlf and "CRLF" or "LF" + } + end + + return {}, { + style.icon_font, "g", + style.font, style.dim, self.separator2, + #core.docs, style.text, " / ", + #core.project_files, " files" + } +end + + +function StatusView:draw() + self:draw_background(style.background2) + + if self.message then + self:draw_items(self.message, false, self.size.y) + end + + local left, right = self:get_items() + self:draw_items(left) + self:draw_items(right, true) +end + + +return StatusView diff --git a/tools/lite/data/core/strict.lua b/tools/lite/data/core/strict.lua new file mode 100644 index 0000000..7d4b9da --- /dev/null +++ b/tools/lite/data/core/strict.lua @@ -0,0 +1,26 @@ +local strict = {} +strict.defined = {} + + +-- used to define a global variable +function global(t) + for k, v in pairs(t) do + strict.defined[k] = true + rawset(_G, k, v) + end +end + + +function strict.__newindex(t, k, v) + error("cannot set undefined variable: " .. k, 2) +end + + +function strict.__index(t, k) + if not strict.defined[k] then + error("cannot get undefined variable: " .. k, 2) + end +end + + +setmetatable(_G, strict) diff --git a/tools/lite/data/core/style.lua b/tools/lite/data/core/style.lua new file mode 100644 index 0000000..ab597c2 --- /dev/null +++ b/tools/lite/data/core/style.lua @@ -0,0 +1,42 @@ +local common = require "core.common" +local style = {} + +style.padding = { x = common.round(14 * SCALE), y = common.round(7 * SCALE) } +style.divider_size = common.round(1 * SCALE) +style.scrollbar_size = common.round(4 * SCALE) +style.caret_width = common.round(2 * SCALE) +style.tab_width = common.round(170 * SCALE) + +style.font = renderer.font.load(EXEDIR .. "/data/fonts/font.ttf", 14 * SCALE) +style.big_font = renderer.font.load(EXEDIR .. "/data/fonts/font.ttf", 34 * SCALE) +style.icon_font = renderer.font.load(EXEDIR .. "/data/fonts/icons.ttf", 14 * SCALE) +style.code_font = renderer.font.load(EXEDIR .. "/data/fonts/monospace.ttf", 13.5 * SCALE) + +style.background = { common.color "#2e2e32" } +style.background2 = { common.color "#252529" } +style.background3 = { common.color "#252529" } +style.text = { common.color "#97979c" } +style.caret = { common.color "#93DDFA" } +style.accent = { common.color "#e1e1e6" } +style.dim = { common.color "#525257" } +style.divider = { common.color "#202024" } +style.selection = { common.color "#48484f" } +style.line_number = { common.color "#525259" } +style.line_number2 = { common.color "#83838f" } +style.line_highlight = { common.color "#343438" } +style.scrollbar = { common.color "#414146" } +style.scrollbar2 = { common.color "#4b4b52" } + +style.syntax = {} +style.syntax["normal"] = { common.color "#e1e1e6" } +style.syntax["symbol"] = { common.color "#e1e1e6" } +style.syntax["comment"] = { common.color "#676b6f" } +style.syntax["keyword"] = { common.color "#E58AC9" } +style.syntax["keyword2"] = { common.color "#F77483" } +style.syntax["number"] = { common.color "#FFA94D" } +style.syntax["literal"] = { common.color "#FFA94D" } +style.syntax["string"] = { common.color "#f7c95c" } +style.syntax["operator"] = { common.color "#93DDFA" } +style.syntax["function"] = { common.color "#93DDFA" } + +return style diff --git a/tools/lite/data/core/syntax.lua b/tools/lite/data/core/syntax.lua new file mode 100644 index 0000000..a763ac7 --- /dev/null +++ b/tools/lite/data/core/syntax.lua @@ -0,0 +1,30 @@ +local common = require "core.common" + +local syntax = {} +syntax.items = {} + +local plain_text_syntax = { patterns = {}, symbols = {} } + + +function syntax.add(t) + table.insert(syntax.items, t) +end + + +local function find(string, field) + for i = #syntax.items, 1, -1 do + local t = syntax.items[i] + if common.match_pattern(string, t[field] or {}) then + return t + end + end +end + +function syntax.get(filename, header) + return find(filename, "files") + or find(header, "headers") + or plain_text_syntax +end + + +return syntax diff --git a/tools/lite/data/core/tokenizer.lua b/tools/lite/data/core/tokenizer.lua new file mode 100644 index 0000000..98aafc7 --- /dev/null +++ b/tools/lite/data/core/tokenizer.lua @@ -0,0 +1,112 @@ +local tokenizer = {} + + +local function push_token(t, type, text) + local prev_type = t[#t-1] + local prev_text = t[#t] + if prev_type and (prev_type == type or prev_text:find("^%s*$")) then + t[#t-1] = type + t[#t] = prev_text .. text + else + table.insert(t, type) + table.insert(t, text) + end +end + + +local function is_escaped(text, idx, esc) + local byte = esc:byte() + local count = 0 + for i = idx - 1, 1, -1 do + if text:byte(i) ~= byte then break end + count = count + 1 + end + return count % 2 == 1 +end + + +local function find_non_escaped(text, pattern, offset, esc) + while true do + local s, e = text:find(pattern, offset) + if not s then break end + if esc and is_escaped(text, s, esc) then + offset = e + 1 + else + return s, e + end + end +end + + +function tokenizer.tokenize(syntax, text, state) + local res = {} + local i = 1 + + if #syntax.patterns == 0 then + return { "normal", text } + end + + while i <= #text do + -- continue trying to match the end pattern of a pair if we have a state set + if state then + local p = syntax.patterns[state] + local s, e = find_non_escaped(text, p.pattern[2], i, p.pattern[3]) + + if s then + push_token(res, p.type, text:sub(i, e)) + state = nil + i = e + 1 + else + push_token(res, p.type, text:sub(i)) + break + end + end + + -- find matching pattern + local matched = false + for n, p in ipairs(syntax.patterns) do + local pattern = (type(p.pattern) == "table") and p.pattern[1] or p.pattern + local s, e = text:find("^" .. pattern, i) + + if s then + -- matched pattern; make and add token + local t = text:sub(s, e) + push_token(res, syntax.symbols[t] or p.type, t) + + -- update state if this was a start|end pattern pair + if type(p.pattern) == "table" then + state = n + end + + -- move cursor past this token + i = e + 1 + matched = true + break + end + end + + -- consume character if we didn't match + if not matched then + push_token(res, "normal", text:sub(i, i)) + i = i + 1 + end + end + + return res, state +end + + +local function iter(t, i) + i = i + 2 + local type, text = t[i], t[i+1] + if type then + return i, type, text + end +end + +function tokenizer.each_token(t) + return iter, t, -1 +end + + +return tokenizer diff --git a/tools/lite/data/core/view.lua b/tools/lite/data/core/view.lua new file mode 100644 index 0000000..ae978a9 --- /dev/null +++ b/tools/lite/data/core/view.lua @@ -0,0 +1,151 @@ +local core = require "core" +local config = require "core.config" +local style = require "core.style" +local common = require "core.common" +local Object = require "core.object" + + +local View = Object:extend() + + +function View:new() + self.position = { x = 0, y = 0 } + self.size = { x = 0, y = 0 } + self.scroll = { x = 0, y = 0, to = { x = 0, y = 0 } } + self.cursor = "arrow" + self.scrollable = false +end + + +function View:move_towards(t, k, dest, rate) + if type(t) ~= "table" then + return self:move_towards(self, t, k, dest, rate) + end + local val = t[k] + if math.abs(val - dest) < 0.5 then + t[k] = dest + else + t[k] = common.lerp(val, dest, rate or 0.5) + end + if val ~= dest then + core.redraw = true + end +end + + +function View:try_close(do_close) + do_close() +end + + +function View:get_name() + return "---" +end + + +function View:get_scrollable_size() + return math.huge +end + + +function View:get_scrollbar_rect() + local sz = self:get_scrollable_size() + if sz <= self.size.y or sz == math.huge then + return 0, 0, 0, 0 + end + local h = math.max(20, self.size.y * self.size.y / sz) + return + self.position.x + self.size.x - style.scrollbar_size, + self.position.y + self.scroll.y * (self.size.y - h) / (sz - self.size.y), + style.scrollbar_size, + h +end + + +function View:scrollbar_overlaps_point(x, y) + local sx, sy, sw, sh = self:get_scrollbar_rect() + return x >= sx - sw * 3 and x < sx + sw and y >= sy and y < sy + sh +end + + +function View:on_mouse_pressed(button, x, y, clicks) + if self:scrollbar_overlaps_point(x, y) then + self.dragging_scrollbar = true + return true + end +end + + +function View:on_mouse_released(button, x, y) + self.dragging_scrollbar = false +end + + +function View:on_mouse_moved(x, y, dx, dy) + if self.dragging_scrollbar then + local delta = self:get_scrollable_size() / self.size.y * dy + self.scroll.to.y = self.scroll.to.y + delta + end + self.hovered_scrollbar = self:scrollbar_overlaps_point(x, y) +end + + +function View:on_text_input(text) + -- no-op +end + + +function View:on_mouse_wheel(y) + if self.scrollable then + self.scroll.to.y = self.scroll.to.y + y * -config.mouse_wheel_scroll + end +end + + +function View:get_content_bounds() + local x = self.scroll.x + local y = self.scroll.y + return x, y, x + self.size.x, y + self.size.y +end + + +function View:get_content_offset() + local x = common.round(self.position.x - self.scroll.x) + local y = common.round(self.position.y - self.scroll.y) + return x, y +end + + +function View:clamp_scroll_position() + local max = self:get_scrollable_size() - self.size.y + self.scroll.to.y = common.clamp(self.scroll.to.y, 0, max) +end + + +function View:update() + self:clamp_scroll_position() + self:move_towards(self.scroll, "x", self.scroll.to.x, 0.3) + self:move_towards(self.scroll, "y", self.scroll.to.y, 0.3) +end + + +function View:draw_background(color) + local x, y = self.position.x, self.position.y + local w, h = self.size.x, self.size.y + renderer.draw_rect(x, y, w + x % 1, h + y % 1, color) +end + + +function View:draw_scrollbar() + local x, y, w, h = self:get_scrollbar_rect() + local highlight = self.hovered_scrollbar or self.dragging_scrollbar + local color = highlight and style.scrollbar2 or style.scrollbar + renderer.draw_rect(x, y, w, h, color) +end + + +function View:draw() +end + + +return View diff --git a/tools/lite/data/fonts/font.ttf b/tools/lite/data/fonts/font.ttf new file mode 100644 index 0000000..2b6392f Binary files /dev/null and b/tools/lite/data/fonts/font.ttf differ diff --git a/tools/lite/data/fonts/icons.ttf b/tools/lite/data/fonts/icons.ttf new file mode 100644 index 0000000..d74fe73 Binary files /dev/null and b/tools/lite/data/fonts/icons.ttf differ diff --git a/tools/lite/data/fonts/monospace.ttf b/tools/lite/data/fonts/monospace.ttf new file mode 100644 index 0000000..5919b5d Binary files /dev/null and b/tools/lite/data/fonts/monospace.ttf differ diff --git a/tools/lite/data/plugins/autocomplete.lua b/tools/lite/data/plugins/autocomplete.lua new file mode 100644 index 0000000..79e4513 --- /dev/null +++ b/tools/lite/data/plugins/autocomplete.lua @@ -0,0 +1,284 @@ +local core = require "core" +local common = require "core.common" +local config = require "core.config" +local command = require "core.command" +local style = require "core.style" +local keymap = require "core.keymap" +local translate = require "core.doc.translate" +local RootView = require "core.rootview" +local DocView = require "core.docview" + +config.autocomplete_max_suggestions = 6 + +local autocomplete = {} +autocomplete.map = {} + + +local mt = { __tostring = function(t) return t.text end } + +function autocomplete.add(t) + local items = {} + for text, info in pairs(t.items) do + info = (type(info) == "string") and info + table.insert(items, setmetatable({ text = text, info = info }, mt)) + end + autocomplete.map[t.name] = { files = t.files or ".*", items = items } +end + + +core.add_thread(function() + local cache = setmetatable({}, { __mode = "k" }) + + local function get_symbols(doc) + local i = 1 + local s = {} + while i < #doc.lines do + for sym in doc.lines[i]:gmatch(config.symbol_pattern) do + s[sym] = true + end + i = i + 1 + if i % 100 == 0 then coroutine.yield() end + end + return s + end + + local function cache_is_valid(doc) + local c = cache[doc] + return c and c.last_change_id == doc:get_change_id() + end + + while true do + local symbols = {} + + -- lift all symbols from all docs + for _, doc in ipairs(core.docs) do + -- update the cache if the doc has changed since the last iteration + if not cache_is_valid(doc) then + cache[doc] = { + last_change_id = doc:get_change_id(), + symbols = get_symbols(doc) + } + end + -- update symbol set with doc's symbol set + for sym in pairs(cache[doc].symbols) do + symbols[sym] = true + end + coroutine.yield() + end + + -- update symbols list + autocomplete.add { name = "open-docs", items = symbols } + + -- wait for next scan + local valid = true + while valid do + coroutine.yield(1) + for _, doc in ipairs(core.docs) do + if not cache_is_valid(doc) then + valid = false + end + end + end + + end +end) + + +local partial = "" +local suggestions_idx = 1 +local suggestions = {} +local last_line, last_col + + +local function reset_suggestions() + suggestions_idx = 1 + suggestions = {} +end + + +local function update_suggestions() + local doc = core.active_view.doc + local filename = doc and doc.filename or "" + + -- get all relevant suggestions for given filename + local items = {} + for _, v in pairs(autocomplete.map) do + if common.match_pattern(filename, v.files) then + for _, item in pairs(v.items) do + table.insert(items, item) + end + end + end + + -- fuzzy match, remove duplicates and store + items = common.fuzzy_match(items, partial) + local j = 1 + for i = 1, config.autocomplete_max_suggestions do + suggestions[i] = items[j] + while items[j] and items[i].text == items[j].text do + items[i].info = items[i].info or items[j].info + j = j + 1 + end + end +end + + +local function get_partial_symbol() + local doc = core.active_view.doc + local line2, col2 = doc:get_selection() + local line1, col1 = doc:position_offset(line2, col2, translate.start_of_word) + return doc:get_text(line1, col1, line2, col2) +end + + +local function get_active_view() + if getmetatable(core.active_view) == DocView then + return core.active_view + end +end + + +local function get_suggestions_rect(av) + if #suggestions == 0 then + return 0, 0, 0, 0 + end + + local line, col = av.doc:get_selection() + local x, y = av:get_line_screen_position(line) + x = x + av:get_col_x_offset(line, col - #partial) + y = y + av:get_line_height() + style.padding.y + local font = av:get_font() + local th = font:get_height() + + local max_width = 0 + for _, s in ipairs(suggestions) do + local w = font:get_width(s.text) + if s.info then + w = w + style.font:get_width(s.info) + style.padding.x + end + max_width = math.max(max_width, w) + end + + return + x - style.padding.x, + y - style.padding.y, + max_width + style.padding.x * 2, + #suggestions * (th + style.padding.y) + style.padding.y +end + + +local function draw_suggestions_box(av) + -- draw background rect + local rx, ry, rw, rh = get_suggestions_rect(av) + renderer.draw_rect(rx, ry, rw, rh, style.background3) + + -- draw text + local font = av:get_font() + local lh = font:get_height() + style.padding.y + local y = ry + style.padding.y / 2 + for i, s in ipairs(suggestions) do + local color = (i == suggestions_idx) and style.accent or style.text + common.draw_text(font, color, s.text, "left", rx + style.padding.x, y, rw, lh) + if s.info then + color = (i == suggestions_idx) and style.text or style.dim + common.draw_text(style.font, color, s.info, "right", rx, y, rw - style.padding.x, lh) + end + y = y + lh + end +end + + +-- patch event logic into RootView +local on_text_input = RootView.on_text_input +local update = RootView.update +local draw = RootView.draw + + +RootView.on_text_input = function(...) + on_text_input(...) + + local av = get_active_view() + if av then + -- update partial symbol and suggestions + partial = get_partial_symbol() + if #partial >= 3 then + update_suggestions() + last_line, last_col = av.doc:get_selection() + else + reset_suggestions() + end + + -- scroll if rect is out of bounds of view + local _, y, _, h = get_suggestions_rect(av) + local limit = av.position.y + av.size.y + if y + h > limit then + av.scroll.to.y = av.scroll.y + y + h - limit + end + end +end + + +RootView.update = function(...) + update(...) + + local av = get_active_view() + if av then + -- reset suggestions if caret was moved + local line, col = av.doc:get_selection() + if line ~= last_line or col ~= last_col then + reset_suggestions() + end + end +end + + +RootView.draw = function(...) + draw(...) + + local av = get_active_view() + if av then + -- draw suggestions box after everything else + core.root_view:defer_draw(draw_suggestions_box, av) + end +end + + +local function predicate() + return get_active_view() and #suggestions > 0 +end + + +command.add(predicate, { + ["autocomplete:complete"] = function() + local doc = core.active_view.doc + local line, col = doc:get_selection() + local text = suggestions[suggestions_idx].text + doc:insert(line, col, text) + doc:remove(line, col, line, col - #partial) + doc:set_selection(line, col + #text - #partial) + reset_suggestions() + end, + + ["autocomplete:previous"] = function() + suggestions_idx = math.max(suggestions_idx - 1, 1) + end, + + ["autocomplete:next"] = function() + suggestions_idx = math.min(suggestions_idx + 1, #suggestions) + end, + + ["autocomplete:cancel"] = function() + reset_suggestions() + end, +}) + + +keymap.add { + ["tab"] = "autocomplete:complete", + ["up"] = "autocomplete:previous", + ["down"] = "autocomplete:next", + ["escape"] = "autocomplete:cancel", +} + + +return autocomplete diff --git a/tools/lite/data/plugins/autoreload.lua b/tools/lite/data/plugins/autoreload.lua new file mode 100644 index 0000000..a077b6d --- /dev/null +++ b/tools/lite/data/plugins/autoreload.lua @@ -0,0 +1,61 @@ +local core = require "core" +local config = require "core.config" +local Doc = require "core.doc" + + +local times = setmetatable({}, { __mode = "k" }) + +local function update_time(doc) + local info = system.get_file_info(doc.filename) + times[doc] = info.modified +end + + +local function reload_doc(doc) + local fp = io.open(doc.filename, "r") + local text = fp:read("*a") + fp:close() + + local sel = { doc:get_selection() } + doc:remove(1, 1, math.huge, math.huge) + doc:insert(1, 1, text:gsub("\r", ""):gsub("\n$", "")) + doc:set_selection(table.unpack(sel)) + + update_time(doc) + doc:clean() + core.log_quiet("Auto-reloaded doc \"%s\"", doc.filename) +end + + +core.add_thread(function() + while true do + -- check all doc modified times + for _, doc in ipairs(core.docs) do + local info = system.get_file_info(doc.filename or "") + if info and times[doc] ~= info.modified then + reload_doc(doc) + end + coroutine.yield() + end + + -- wait for next scan + coroutine.yield(config.project_scan_rate) + end +end) + + +-- patch `Doc.save|load` to store modified time +local load = Doc.load +local save = Doc.save + +Doc.load = function(self, ...) + local res = load(self, ...) + update_time(self) + return res +end + +Doc.save = function(self, ...) + local res = save(self, ...) + update_time(self) + return res +end diff --git a/tools/lite/data/plugins/language_c.lua b/tools/lite/data/plugins/language_c.lua new file mode 100644 index 0000000..8e8ee98 --- /dev/null +++ b/tools/lite/data/plugins/language_c.lua @@ -0,0 +1,59 @@ +local syntax = require "core.syntax" + +syntax.add { + files = { "%.c$", "%.h$", "%.inl$", "%.cpp$", "%.hpp$" }, + comment = "//", + patterns = { + { pattern = "//.-\n", type = "comment" }, + { pattern = { "/%*", "%*/" }, type = "comment" }, + { pattern = { "#", "[^\\]\n" }, type = "comment" }, + { pattern = { '"', '"', '\\' }, type = "string" }, + { pattern = { "'", "'", '\\' }, type = "string" }, + { pattern = "-?0x%x+", type = "number" }, + { pattern = "-?%d+[%d%.eE]*f?", type = "number" }, + { pattern = "-?%.?%d+f?", type = "number" }, + { pattern = "[%+%-=/%*%^%%<>!~|&]", type = "operator" }, + { pattern = "[%a_][%w_]*%f[(]", type = "function" }, + { pattern = "[%a_][%w_]*", type = "symbol" }, + }, + symbols = { + ["if"] = "keyword", + ["then"] = "keyword", + ["else"] = "keyword", + ["elseif"] = "keyword", + ["do"] = "keyword", + ["while"] = "keyword", + ["for"] = "keyword", + ["break"] = "keyword", + ["continue"] = "keyword", + ["return"] = "keyword", + ["goto"] = "keyword", + ["struct"] = "keyword", + ["union"] = "keyword", + ["typedef"] = "keyword", + ["enum"] = "keyword", + ["extern"] = "keyword", + ["static"] = "keyword", + ["volatile"] = "keyword", + ["const"] = "keyword", + ["inline"] = "keyword", + ["switch"] = "keyword", + ["case"] = "keyword", + ["default"] = "keyword", + ["auto"] = "keyword", + ["const"] = "keyword", + ["void"] = "keyword", + ["int"] = "keyword2", + ["short"] = "keyword2", + ["long"] = "keyword2", + ["float"] = "keyword2", + ["double"] = "keyword2", + ["char"] = "keyword2", + ["unsigned"] = "keyword2", + ["bool"] = "keyword2", + ["true"] = "literal", + ["false"] = "literal", + ["NULL"] = "literal", + }, +} + diff --git a/tools/lite/data/plugins/language_css.lua b/tools/lite/data/plugins/language_css.lua new file mode 100644 index 0000000..021c5d3 --- /dev/null +++ b/tools/lite/data/plugins/language_css.lua @@ -0,0 +1,23 @@ +local syntax = require "core.syntax" + +syntax.add { + files = { "%.css$" }, + patterns = { + { pattern = "\\.", type = "normal" }, + { pattern = "//.-\n", type = "comment" }, + { pattern = { "/%*", "%*/" }, type = "comment" }, + { pattern = { '"', '"', '\\' }, type = "string" }, + { pattern = { "'", "'", '\\' }, type = "string" }, + { pattern = "[%a][%w-]*%s*%f[:]", type = "keyword" }, + { pattern = "#%x+", type = "string" }, + { pattern = "-?%d+[%d%.]*p[xt]", type = "number" }, + { pattern = "-?%d+[%d%.]*deg", type = "number" }, + { pattern = "-?%d+[%d%.]*", type = "number" }, + { pattern = "[%a_][%w_]*", type = "symbol" }, + { pattern = "#[%a][%w_-]*", type = "keyword2" }, + { pattern = "@[%a][%w_-]*", type = "keyword2" }, + { pattern = "%.[%a][%w_-]*", type = "keyword2" }, + { pattern = "[{}:]", type = "operator" }, + }, + symbols = {}, +} diff --git a/tools/lite/data/plugins/language_js.lua b/tools/lite/data/plugins/language_js.lua new file mode 100644 index 0000000..cf1b124 --- /dev/null +++ b/tools/lite/data/plugins/language_js.lua @@ -0,0 +1,67 @@ +local syntax = require "core.syntax" + +syntax.add { + files = { "%.js$", "%.json$", "%.cson$" }, + comment = "//", + patterns = { + { pattern = "//.-\n", type = "comment" }, + { pattern = { "/%*", "%*/" }, type = "comment" }, + { pattern = { '"', '"', '\\' }, type = "string" }, + { pattern = { "'", "'", '\\' }, type = "string" }, + { pattern = { "`", "`", '\\' }, type = "string" }, + { pattern = "0x[%da-fA-F]+", type = "number" }, + { pattern = "-?%d+[%d%.eE]*", type = "number" }, + { pattern = "-?%.?%d+", type = "number" }, + { pattern = "[%+%-=/%*%^%%<>!~|&]", type = "operator" }, + { pattern = "[%a_][%w_]*%f[(]", type = "function" }, + { pattern = "[%a_][%w_]*", type = "symbol" }, + }, + symbols = { + ["async"] = "keyword", + ["await"] = "keyword", + ["break"] = "keyword", + ["case"] = "keyword", + ["catch"] = "keyword", + ["class"] = "keyword", + ["const"] = "keyword", + ["continue"] = "keyword", + ["debugger"] = "keyword", + ["default"] = "keyword", + ["delete"] = "keyword", + ["do"] = "keyword", + ["else"] = "keyword", + ["export"] = "keyword", + ["extends"] = "keyword", + ["finally"] = "keyword", + ["for"] = "keyword", + ["function"] = "keyword", + ["get"] = "keyword", + ["if"] = "keyword", + ["import"] = "keyword", + ["in"] = "keyword", + ["instanceof"] = "keyword", + ["let"] = "keyword", + ["new"] = "keyword", + ["return"] = "keyword", + ["set"] = "keyword", + ["static"] = "keyword", + ["super"] = "keyword", + ["switch"] = "keyword", + ["throw"] = "keyword", + ["try"] = "keyword", + ["typeof"] = "keyword", + ["var"] = "keyword", + ["void"] = "keyword", + ["while"] = "keyword", + ["with"] = "keyword", + ["yield"] = "keyword", + ["true"] = "literal", + ["false"] = "literal", + ["null"] = "literal", + ["undefined"] = "literal", + ["arguments"] = "keyword2", + ["Infinity"] = "keyword2", + ["NaN"] = "keyword2", + ["this"] = "keyword2", + }, +} diff --git a/tools/lite/data/plugins/language_lua.lua b/tools/lite/data/plugins/language_lua.lua new file mode 100644 index 0000000..915d273 --- /dev/null +++ b/tools/lite/data/plugins/language_lua.lua @@ -0,0 +1,50 @@ +local syntax = require "core.syntax" + +syntax.add { + files = "%.lua$", + headers = "^#!.*[ /]lua", + comment = "--", + patterns = { + { pattern = { '"', '"', '\\' }, type = "string" }, + { pattern = { "'", "'", '\\' }, type = "string" }, + { pattern = { "%[%[", "%]%]" }, type = "string" }, + { pattern = { "%-%-%[%[", "%]%]"}, type = "comment" }, + { pattern = "%-%-.-\n", type = "comment" }, + { pattern = "-?0x%x+", type = "number" }, + { pattern = "-?%d+[%d%.eE]*", type = "number" }, + { pattern = "-?%.?%d+", type = "number" }, + { pattern = "<%a+>", type = "keyword2" }, + { pattern = "%.%.%.?", type = "operator" }, + { pattern = "[<>~=]=", type = "operator" }, + { pattern = "[%+%-=/%*%^%%#<>]", type = "operator" }, + { pattern = "[%a_][%w_]*%s*%f[(\"{]", type = "function" }, + { pattern = "[%a_][%w_]*", type = "symbol" }, + { pattern = "::[%a_][%w_]*::", type = "function" }, + }, + symbols = { + ["if"] = "keyword", + ["then"] = "keyword", + ["else"] = "keyword", + ["elseif"] = "keyword", + ["end"] = "keyword", + ["do"] = "keyword", + ["function"] = "keyword", + ["repeat"] = "keyword", + ["until"] = "keyword", + ["while"] = "keyword", + ["for"] = "keyword", + ["break"] = "keyword", + ["return"] = "keyword", + ["local"] = "keyword", + ["in"] = "keyword", + ["not"] = "keyword", + ["and"] = "keyword", + ["or"] = "keyword", + ["goto"] = "keyword", + ["self"] = "keyword2", + ["true"] = "literal", + ["false"] = "literal", + ["nil"] = "literal", + }, +} + diff --git a/tools/lite/data/plugins/language_md.lua b/tools/lite/data/plugins/language_md.lua new file mode 100644 index 0000000..9f0f14e --- /dev/null +++ b/tools/lite/data/plugins/language_md.lua @@ -0,0 +1,21 @@ +local syntax = require "core.syntax" + +syntax.add { + files = { "%.md$", "%.markdown$" }, + patterns = { + { pattern = "\\.", type = "normal" }, + { pattern = { "" }, type = "comment" }, + { pattern = { "```", "```" }, type = "string" }, + { pattern = { "``", "``", "\\" }, type = "string" }, + { pattern = { "`", "`", "\\" }, type = "string" }, + { pattern = { "~~", "~~", "\\" }, type = "keyword2" }, + { pattern = "%-%-%-+", type = "comment" }, + { pattern = "%*%s+", type = "operator" }, + { pattern = { "%*", "[%*\n]", "\\" }, type = "operator" }, + { pattern = { "%_", "[%_\n]", "\\" }, type = "keyword2" }, + { pattern = "#.-\n", type = "keyword" }, + { pattern = "!?%[.-%]%(.-%)", type = "function" }, + { pattern = "https?://%S+", type = "function" }, + }, + symbols = { }, +} diff --git a/tools/lite/data/plugins/language_python.lua b/tools/lite/data/plugins/language_python.lua new file mode 100644 index 0000000..4a6ada3 --- /dev/null +++ b/tools/lite/data/plugins/language_python.lua @@ -0,0 +1,55 @@ +local syntax = require "core.syntax" + +syntax.add { + files = "%.py$", + headers = "^#!.*[ /]python", + comment = "#", + patterns = { + { pattern = { "#", "\n" }, type = "comment" }, + { pattern = { '[ruU]?"', '"', '\\' }, type = "string" }, + { pattern = { "[ruU]?'", "'", '\\' }, type = "string" }, + { pattern = { '"""', '"""' }, type = "string" }, + { pattern = "0x[%da-fA-F]+", type = "number" }, + { pattern = "-?%d+[%d%.eE]*", type = "number" }, + { pattern = "-?%.?%d+", type = "number" }, + { pattern = "[%+%-=/%*%^%%<>!~|&]", type = "operator" }, + { pattern = "[%a_][%w_]*%f[(]", type = "function" }, + { pattern = "[%a_][%w_]*", type = "symbol" }, + }, + symbols = { + ["class"] = "keyword", + ["finally"] = "keyword", + ["is"] = "keyword", + ["return"] = "keyword", + ["continue"] = "keyword", + ["for"] = "keyword", + ["lambda"] = "keyword", + ["try"] = "keyword", + ["def"] = "keyword", + ["from"] = "keyword", + ["nonlocal"] = "keyword", + ["while"] = "keyword", + ["and"] = "keyword", + ["global"] = "keyword", + ["not"] = "keyword", + ["with"] = "keyword", + ["as"] = "keyword", + ["elif"] = "keyword", + ["if"] = "keyword", + ["or"] = "keyword", + ["else"] = "keyword", + ["import"] = "keyword", + ["pass"] = "keyword", + ["break"] = "keyword", + ["except"] = "keyword", + ["in"] = "keyword", + ["del"] = "keyword", + ["raise"] = "keyword", + ["yield"] = "keyword", + ["assert"] = "keyword", + ["self"] = "keyword2", + ["None"] = "literal", + ["True"] = "literal", + ["False"] = "literal", + } +} diff --git a/tools/lite/data/plugins/language_xml.lua b/tools/lite/data/plugins/language_xml.lua new file mode 100644 index 0000000..5240bdc --- /dev/null +++ b/tools/lite/data/plugins/language_xml.lua @@ -0,0 +1,21 @@ +local syntax = require "core.syntax" + +syntax.add { + files = { "%.xml$", "%.html?$" }, + headers = "<%?xml", + patterns = { + { pattern = { "" }, type = "comment" }, + { pattern = { '%f[^>][^<]', '%f[<]' }, type = "normal" }, + { pattern = { '"', '"', '\\' }, type = "string" }, + { pattern = { "'", "'", '\\' }, type = "string" }, + { pattern = "0x[%da-fA-F]+", type = "number" }, + { pattern = "-?%d+[%d%.]*f?", type = "number" }, + { pattern = "-?%.?%d+f?", type = "number" }, + { pattern = "%f[^<]![%a_][%w_]*", type = "keyword2" }, + { pattern = "%f[^<][%a_][%w_]*", type = "function" }, + { pattern = "%f[^<]/[%a_][%w_]*", type = "function" }, + { pattern = "[%a_][%w_]*", type = "keyword" }, + { pattern = "[/<>=]", type = "operator" }, + }, + symbols = {}, +} diff --git a/tools/lite/data/plugins/macro.lua b/tools/lite/data/plugins/macro.lua new file mode 100644 index 0000000..3458977 --- /dev/null +++ b/tools/lite/data/plugins/macro.lua @@ -0,0 +1,69 @@ +local core = require "core" +local command = require "core.command" +local keymap = require "core.keymap" + +local handled_events = { + ["keypressed"] = true, + ["keyreleased"] = true, + ["textinput"] = true, +} + +local state = "stopped" +local event_buffer = {} +local modkeys = {} + +local on_event = core.on_event + +core.on_event = function(type, ...) + local res = on_event(type, ...) + if state == "recording" and handled_events[type] then + table.insert(event_buffer, { type, ... }) + end + return res +end + + +local function clone(t) + local res = {} + for k, v in pairs(t) do res[k] = v end + return res +end + + +local function predicate() + return state ~= "playing" +end + + +command.add(predicate, { + ["macro:toggle-record"] = function() + if state == "stopped" then + state = "recording" + event_buffer = {} + modkeys = clone(keymap.modkeys) + core.log("Recording macro...") + else + state = "stopped" + core.log("Stopped recording macro (%d events)", #event_buffer) + end + end, + + ["macro:play"] = function() + state = "playing" + core.log("Playing macro... (%d events)", #event_buffer) + local mk = keymap.modkeys + keymap.modkeys = clone(modkeys) + for _, ev in ipairs(event_buffer) do + on_event(table.unpack(ev)) + core.root_view:update() + end + keymap.modkeys = mk + state = "stopped" + end, +}) + + +keymap.add { + ["ctrl+shift+;"] = "macro:toggle-record", + ["ctrl+;"] = "macro:play", +} diff --git a/tools/lite/data/plugins/projectsearch.lua b/tools/lite/data/plugins/projectsearch.lua new file mode 100644 index 0000000..884e66a --- /dev/null +++ b/tools/lite/data/plugins/projectsearch.lua @@ -0,0 +1,271 @@ +local core = require "core" +local common = require "core.common" +local keymap = require "core.keymap" +local command = require "core.command" +local style = require "core.style" +local View = require "core.view" + + +local ResultsView = View:extend() + + +function ResultsView:new(text, fn) + ResultsView.super.new(self) + self.scrollable = true + self.brightness = 0 + self:begin_search(text, fn) +end + + +function ResultsView:get_name() + return "Search Results" +end + + +local function find_all_matches_in_file(t, filename, fn) + local fp = io.open(filename) + if not fp then return t end + local n = 1 + for line in fp:lines() do + local s = fn(line) + if s then + table.insert(t, { file = filename, text = line, line = n, col = s }) + core.redraw = true + end + if n % 100 == 0 then coroutine.yield() end + n = n + 1 + core.redraw = true + end + fp:close() +end + + +function ResultsView:begin_search(text, fn) + self.search_args = { text, fn } + self.results = {} + self.last_file_idx = 1 + self.query = text + self.searching = true + self.selected_idx = 0 + + core.add_thread(function() + for i, file in ipairs(core.project_files) do + if file.type == "file" then + find_all_matches_in_file(self.results, file.filename, fn) + end + self.last_file_idx = i + end + self.searching = false + self.brightness = 100 + core.redraw = true + end, self.results) + + self.scroll.to.y = 0 +end + + +function ResultsView:refresh() + self:begin_search(table.unpack(self.search_args)) +end + + +function ResultsView:on_mouse_moved(mx, my, ...) + ResultsView.super.on_mouse_moved(self, mx, my, ...) + self.selected_idx = 0 + for i, item, x,y,w,h in self:each_visible_result() do + if mx >= x and my >= y and mx < x + w and my < y + h then + self.selected_idx = i + break + end + end +end + + +function ResultsView:on_mouse_pressed(...) + local caught = ResultsView.super.on_mouse_pressed(self, ...) + if not caught then + self:open_selected_result() + end +end + + +function ResultsView:open_selected_result() + local res = self.results[self.selected_idx] + if not res then + return + end + core.try(function() + local dv = core.root_view:open_doc(core.open_doc(res.file)) + core.root_view.root_node:update_layout() + dv.doc:set_selection(res.line, res.col) + dv:scroll_to_line(res.line, false, true) + end) +end + + +function ResultsView:update() + self:move_towards("brightness", 0, 0.1) + ResultsView.super.update(self) +end + + +function ResultsView:get_results_yoffset() + return style.font:get_height() + style.padding.y * 3 +end + + +function ResultsView:get_line_height() + return style.padding.y + style.font:get_height() +end + + +function ResultsView:get_scrollable_size() + return self:get_results_yoffset() + #self.results * self:get_line_height() +end + + +function ResultsView:get_visible_results_range() + local lh = self:get_line_height() + local oy = self:get_results_yoffset() + local min = math.max(1, math.floor((self.scroll.y - oy) / lh)) + return min, min + math.floor(self.size.y / lh) + 1 +end + + +function ResultsView:each_visible_result() + return coroutine.wrap(function() + local lh = self:get_line_height() + local x, y = self:get_content_offset() + local min, max = self:get_visible_results_range() + y = y + self:get_results_yoffset() + lh * (min - 1) + for i = min, max do + local item = self.results[i] + if not item then break end + coroutine.yield(i, item, x, y, self.size.x, lh) + y = y + lh + end + end) +end + + +function ResultsView:scroll_to_make_selected_visible() + local h = self:get_line_height() + local y = self:get_results_yoffset() + h * (self.selected_idx - 1) + self.scroll.to.y = math.min(self.scroll.to.y, y) + self.scroll.to.y = math.max(self.scroll.to.y, y + h - self.size.y) +end + + +function ResultsView:draw() + self:draw_background(style.background) + + -- status + local ox, oy = self:get_content_offset() + local x, y = ox + style.padding.x, oy + style.padding.y + local per = self.last_file_idx / #core.project_files + local text + if self.searching then + text = string.format("Searching %d%% (%d of %d files, %d matches) for %q...", + per * 100, self.last_file_idx, #core.project_files, + #self.results, self.query) + else + text = string.format("Found %d matches for %q", + #self.results, self.query) + end + local color = common.lerp(style.text, style.accent, self.brightness / 100) + renderer.draw_text(style.font, text, x, y, color) + + -- horizontal line + local yoffset = self:get_results_yoffset() + local x = ox + style.padding.x + local w = self.size.x - style.padding.x * 2 + local h = style.divider_size + local color = common.lerp(style.dim, style.text, self.brightness / 100) + renderer.draw_rect(x, oy + yoffset - style.padding.y, w, h, color) + if self.searching then + renderer.draw_rect(x, oy + yoffset - style.padding.y, w * per, h, style.text) + end + + -- results + local y1, y2 = self.position.y, self.position.y + self.size.y + for i, item, x,y,w,h in self:each_visible_result() do + local color = style.text + if i == self.selected_idx then + color = style.accent + renderer.draw_rect(x, y, w, h, style.line_highlight) + end + x = x + style.padding.x + local text = string.format("%s at line %d (col %d): ", item.file, item.line, item.col) + x = common.draw_text(style.font, style.dim, text, "left", x, y, w, h) + x = common.draw_text(style.code_font, color, item.text, "left", x, y, w, h) + end + + self:draw_scrollbar() +end + + +local function begin_search(text, fn) + if text == "" then + core.error("Expected non-empty string") + return + end + local rv = ResultsView(text, fn) + core.root_view:get_active_node():add_view(rv) +end + + +command.add(nil, { + ["project-search:find"] = function() + core.command_view:enter("Find Text In Project", function(text) + text = text:lower() + begin_search(text, function(line_text) + return line_text:lower():find(text, nil, true) + end) + end) + end, + + ["project-search:find-pattern"] = function() + core.command_view:enter("Find Pattern In Project", function(text) + begin_search(text, function(line_text) return line_text:find(text) end) + end) + end, + + ["project-search:fuzzy-find"] = function() + core.command_view:enter("Fuzzy Find Text In Project", function(text) + begin_search(text, function(line_text) + return common.fuzzy_match(line_text, text) and 1 + end) + end) + end, +}) + + +command.add(ResultsView, { + ["project-search:select-previous"] = function() + local view = core.active_view + view.selected_idx = math.max(view.selected_idx - 1, 1) + view:scroll_to_make_selected_visible() + end, + + ["project-search:select-next"] = function() + local view = core.active_view + view.selected_idx = math.min(view.selected_idx + 1, #view.results) + view:scroll_to_make_selected_visible() + end, + + ["project-search:open-selected"] = function() + core.active_view:open_selected_result() + end, + + ["project-search:refresh"] = function() + core.active_view:refresh() + end, +}) + +keymap.add { + ["f5"] = "project-search:refresh", + ["ctrl+shift+f"] = "project-search:find", + ["up"] = "project-search:select-previous", + ["down"] = "project-search:select-next", + ["return"] = "project-search:open-selected", +} diff --git a/tools/lite/data/plugins/quote.lua b/tools/lite/data/plugins/quote.lua new file mode 100644 index 0000000..b17407d --- /dev/null +++ b/tools/lite/data/plugins/quote.lua @@ -0,0 +1,30 @@ +local core = require "core" +local command = require "core.command" +local keymap = require "core.keymap" + + +local escapes = { + ["\\"] = "\\\\", + ["\""] = "\\\"", + ["\n"] = "\\n", + ["\r"] = "\\r", + ["\t"] = "\\t", + ["\b"] = "\\b", +} + +local function replace(chr) + return escapes[chr] or string.format("\\x%02x", chr:byte()) +end + + +command.add("core.docview", { + ["quote:quote"] = function() + core.active_view.doc:replace(function(text) + return '"' .. text:gsub("[\0-\31\\\"]", replace) .. '"' + end) + end, +}) + +keymap.add { + ["ctrl+'"] = "quote:quote", +} diff --git a/tools/lite/data/plugins/reflow.lua b/tools/lite/data/plugins/reflow.lua new file mode 100644 index 0000000..95090a6 --- /dev/null +++ b/tools/lite/data/plugins/reflow.lua @@ -0,0 +1,63 @@ +local core = require "core" +local config = require "core.config" +local command = require "core.command" +local keymap = require "core.keymap" + + +local function wordwrap_text(text, limit) + local t = {} + local n = 0 + + for word in text:gmatch("%S+") do + if n + #word > limit then + table.insert(t, "\n") + n = 0 + elseif #t > 0 then + table.insert(t, " ") + end + table.insert(t, word) + n = n + #word + 1 + end + + return table.concat(t) +end + + +command.add("core.docview", { + ["reflow:reflow"] = function() + local doc = core.active_view.doc + doc:replace(function(text) + local prefix_set = "[^%w\n%[%](){}`'\"]*" + + -- get line prefix and trailing whitespace + local prefix1 = text:match("^\n*" .. prefix_set) + local prefix2 = text:match("\n(" .. prefix_set .. ")", #prefix1+1) + local trailing = text:match("%s*$") + if not prefix2 or prefix2 == "" then + prefix2 = prefix1 + end + + -- strip all line prefixes and trailing whitespace + text = text:sub(#prefix1+1, -#trailing - 1):gsub("\n" .. prefix_set, "\n") + + -- split into blocks, wordwrap and join + local line_limit = config.line_limit - #prefix1 + local blocks = {} + text = text:gsub("\n\n", "\0") + for block in text:gmatch("%Z+") do + table.insert(blocks, wordwrap_text(block, line_limit)) + end + text = table.concat(blocks, "\n\n") + + -- add prefix to start of lines + text = prefix1 .. text:gsub("\n", "\n" .. prefix2) .. trailing + + return text + end) + end, +}) + + +keymap.add { + ["ctrl+shift+q"] = "reflow:reflow" +} diff --git a/tools/lite/data/plugins/tabularize.lua b/tools/lite/data/plugins/tabularize.lua new file mode 100644 index 0000000..f4dda1f --- /dev/null +++ b/tools/lite/data/plugins/tabularize.lua @@ -0,0 +1,60 @@ +local core = require "core" +local command = require "core.command" +local translate = require "core.doc.translate" + + +local function gmatch_to_array(text, ptn) + local res = {} + for x in text:gmatch(ptn) do + table.insert(res, x) + end + return res +end + + +local function tabularize_lines(lines, delim) + local rows = {} + local cols = {} + + -- split lines at delimiters and get maximum width of columns + local ptn = "[^" .. delim:sub(1,1):gsub("%W", "%%%1") .. "]+" + for i, line in ipairs(lines) do + rows[i] = gmatch_to_array(line, ptn) + for j, col in ipairs(rows[i]) do + cols[j] = math.max(#col, cols[j] or 0) + end + end + + -- pad columns with space + for _, row in ipairs(rows) do + for i = 1, #row - 1 do + row[i] = row[i] .. string.rep(" ", cols[i] - #row[i]) + end + end + + -- write columns back to lines array + for i, line in ipairs(lines) do + lines[i] = table.concat(rows[i], delim) + end +end + + +command.add("core.docview", { + ["tabularize:tabularize"] = function() + core.command_view:enter("Tabularize On Delimiter", function(delim) + if delim == "" then delim = " " end + + local doc = core.active_view.doc + local line1, col1, line2, col2, swap = doc:get_selection(true) + line1, col1 = doc:position_offset(line1, col1, translate.start_of_line) + line2, col2 = doc:position_offset(line2, col2, translate.end_of_line) + doc:set_selection(line1, col1, line2, col2, swap) + + doc:replace(function(text) + local lines = gmatch_to_array(text, "[^\n]*\n?") + tabularize_lines(lines, delim) + return table.concat(lines) + end) + end) + end, +}) diff --git a/tools/lite/data/plugins/treeview.lua b/tools/lite/data/plugins/treeview.lua new file mode 100644 index 0000000..8aa53ff --- /dev/null +++ b/tools/lite/data/plugins/treeview.lua @@ -0,0 +1,197 @@ +local core = require "core" +local common = require "core.common" +local command = require "core.command" +local config = require "core.config" +local keymap = require "core.keymap" +local style = require "core.style" +local View = require "core.view" + +config.treeview_size = 200 * SCALE + +local function get_depth(filename) + local n = 0 + for sep in filename:gmatch("[\\/]") do + n = n + 1 + end + return n +end + + +local TreeView = View:extend() + +function TreeView:new() + TreeView.super.new(self) + self.scrollable = true + self.visible = true + self.init_size = true + self.cache = {} +end + + +function TreeView:get_cached(item) + local t = self.cache[item.filename] + if not t then + t = {} + t.filename = item.filename + t.abs_filename = system.absolute_path(item.filename) + t.name = t.filename:match("[^\\/]+$") + t.depth = get_depth(t.filename) + t.type = item.type + self.cache[t.filename] = t + end + return t +end + + +function TreeView:get_name() + return "Project" +end + + +function TreeView:get_item_height() + return style.font:get_height() + style.padding.y +end + + +function TreeView:check_cache() + -- invalidate cache's skip values if project_files has changed + if core.project_files ~= self.last_project_files then + for _, v in pairs(self.cache) do + v.skip = nil + end + self.last_project_files = core.project_files + end +end + + +function TreeView:each_item() + return coroutine.wrap(function() + self:check_cache() + local ox, oy = self:get_content_offset() + local y = oy + style.padding.y + local w = self.size.x + local h = self:get_item_height() + + local i = 1 + while i <= #core.project_files do + local item = core.project_files[i] + local cached = self:get_cached(item) + + coroutine.yield(cached, ox, y, w, h) + y = y + h + i = i + 1 + + if not cached.expanded then + if cached.skip then + i = cached.skip + else + local depth = cached.depth + while i <= #core.project_files do + local filename = core.project_files[i].filename + if get_depth(filename) <= depth then break end + i = i + 1 + end + cached.skip = i + end + end + end + end) +end + + +function TreeView:on_mouse_moved(px, py) + self.hovered_item = nil + for item, x,y,w,h in self:each_item() do + if px > x and py > y and px <= x + w and py <= y + h then + self.hovered_item = item + break + end + end +end + + +function TreeView:on_mouse_pressed(button, x, y) + if not self.hovered_item then + return + elseif self.hovered_item.type == "dir" then + self.hovered_item.expanded = not self.hovered_item.expanded + else + core.try(function() + core.root_view:open_doc(core.open_doc(self.hovered_item.filename)) + end) + end +end + + +function TreeView:update() + -- update width + local dest = self.visible and config.treeview_size or 0 + if self.init_size then + self.size.x = dest + self.init_size = false + else + self:move_towards(self.size, "x", dest) + end + + TreeView.super.update(self) +end + + +function TreeView:draw() + self:draw_background(style.background2) + + local icon_width = style.icon_font:get_width("D") + local spacing = style.font:get_width(" ") * 2 + + local doc = core.active_view.doc + local active_filename = doc and system.absolute_path(doc.filename or "") + + for item, x,y,w,h in self:each_item() do + local color = style.text + + -- highlight active_view doc + if item.abs_filename == active_filename then + color = style.accent + end + + -- hovered item background + if item == self.hovered_item then + renderer.draw_rect(x, y, w, h, style.line_highlight) + color = style.accent + end + + -- icons + x = x + item.depth * style.padding.x + style.padding.x + if item.type == "dir" then + local icon1 = item.expanded and "-" or "+" + local icon2 = item.expanded and "D" or "d" + common.draw_text(style.icon_font, color, icon1, nil, x, y, 0, h) + x = x + style.padding.x + common.draw_text(style.icon_font, color, icon2, nil, x, y, 0, h) + x = x + icon_width + else + x = x + style.padding.x + common.draw_text(style.icon_font, color, "f", nil, x, y, 0, h) + x = x + icon_width + end + + -- text + x = x + spacing + x = common.draw_text(style.font, color, item.name, nil, x, y, 0, h) + end +end + + +-- init +local view = TreeView() +local node = core.root_view:get_active_node() +node:split("left", view, true) + +-- register commands and keymap +command.add(nil, { + ["treeview:toggle"] = function() + view.visible = not view.visible + end, +}) + +keymap.add { ["ctrl+\\"] = "treeview:toggle" } diff --git a/tools/lite/data/plugins/trimwhitespace.lua b/tools/lite/data/plugins/trimwhitespace.lua new file mode 100644 index 0000000..d4d25c8 --- /dev/null +++ b/tools/lite/data/plugins/trimwhitespace.lua @@ -0,0 +1,36 @@ +local core = require "core" +local command = require "core.command" +local Doc = require "core.doc" + + +local function trim_trailing_whitespace(doc) + local cline, ccol = doc:get_selection() + for i = 1, #doc.lines do + local old_text = doc:get_text(i, 1, i, math.huge) + local new_text = old_text:gsub("%s*$", "") + + -- don't remove whitespace which would cause the caret to reposition + if cline == i and ccol > #new_text then + new_text = old_text:sub(1, ccol - 1) + end + + if old_text ~= new_text then + doc:insert(i, 1, new_text) + doc:remove(i, #new_text + 1, i, math.huge) + end + end +end + + +command.add("core.docview", { + ["trim-whitespace:trim-trailing-whitespace"] = function() + trim_trailing_whitespace(core.active_view.doc) + end, +}) + + +local save = Doc.save +Doc.save = function(self, ...) + trim_trailing_whitespace(self) + save(self, ...) +end diff --git a/tools/lite/data/user/colors/fall.lua b/tools/lite/data/user/colors/fall.lua new file mode 100644 index 0000000..0cab762 --- /dev/null +++ b/tools/lite/data/user/colors/fall.lua @@ -0,0 +1,28 @@ +local style = require "core.style" +local common = require "core.common" + +style.background = { common.color "#343233" } +style.background2 = { common.color "#2c2a2b" } +style.background3 = { common.color "#2c2a2b" } +style.text = { common.color "#c4b398" } +style.caret = { common.color "#61efce" } +style.accent = { common.color "#ffd152" } +style.dim = { common.color "#615d5f" } +style.divider = { common.color "#242223" } +style.selection = { common.color "#454244" } +style.line_number = { common.color "#454244" } +style.line_number2 = { common.color "#615d5f" } +style.line_highlight = { common.color "#383637" } +style.scrollbar = { common.color "#454344" } +style.scrollbar2 = { common.color "#524F50" } + +style.syntax["normal"] = { common.color "#efdab9" } +style.syntax["symbol"] = { common.color "#efdab9" } +style.syntax["comment"] = { common.color "#615d5f" } +style.syntax["keyword"] = { common.color "#d36e2d" } +style.syntax["keyword2"] = { common.color "#ef6179" } +style.syntax["number"] = { common.color "#ffd152" } +style.syntax["literal"] = { common.color "#ffd152" } +style.syntax["string"] = { common.color "#ffd152" } +style.syntax["operator"] = { common.color "#efdab9" } +style.syntax["function"] = { common.color "#61efce" } diff --git a/tools/lite/data/user/colors/summer.lua b/tools/lite/data/user/colors/summer.lua new file mode 100644 index 0000000..5e48cf7 --- /dev/null +++ b/tools/lite/data/user/colors/summer.lua @@ -0,0 +1,28 @@ +local style = require "core.style" +local common = require "core.common" + +style.background = { common.color "#fbfbfb" } +style.background2 = { common.color "#f2f2f2" } +style.background3 = { common.color "#f2f2f2" } +style.text = { common.color "#404040" } +style.caret = { common.color "#fc1785" } +style.accent = { common.color "#fc1785" } +style.dim = { common.color "#b0b0b0" } +style.divider = { common.color "#e8e8e8" } +style.selection = { common.color "#b7dce8" } +style.line_number = { common.color "#d0d0d0" } +style.line_number2 = { common.color "#808080" } +style.line_highlight = { common.color "#f2f2f2" } +style.scrollbar = { common.color "#e0e0e0" } +style.scrollbar2 = { common.color "#c0c0c0" } + +style.syntax["normal"] = { common.color "#181818" } +style.syntax["symbol"] = { common.color "#181818" } +style.syntax["comment"] = { common.color "#22a21f" } +style.syntax["keyword"] = { common.color "#fb6620" } +style.syntax["keyword2"] = { common.color "#fc1785" } +style.syntax["number"] = { common.color "#1586d2" } +style.syntax["literal"] = { common.color "#1586d2" } +style.syntax["string"] = { common.color "#1586d2" } +style.syntax["operator"] = { common.color "#fb6620" } +style.syntax["function"] = { common.color "#fc1785" } diff --git a/tools/lite/data/user/init.lua b/tools/lite/data/user/init.lua new file mode 100644 index 0000000..2839fe1 --- /dev/null +++ b/tools/lite/data/user/init.lua @@ -0,0 +1,13 @@ +-- put user settings here +-- this module will be loaded after everything else when the application starts + +local keymap = require "core.keymap" +local config = require "core.config" +local style = require "core.style" + +-- light theme: +-- require "user.colors.summer" + +-- key binding: +-- keymap.add { ["ctrl+escape"] = "core:quit" } + diff --git a/tools/lite/lite b/tools/lite/lite new file mode 100644 index 0000000..3266cbd Binary files /dev/null and b/tools/lite/lite differ diff --git a/tools/lite/lite.exe b/tools/lite/lite.exe new file mode 100644 index 0000000..f464a02 Binary files /dev/null and b/tools/lite/lite.exe differ