diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..41f66c7c --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,35 @@ +"Category: CI": + - .github/workflows/* + +"Category: Meta": + - ./* + - .github/* + - .github/ISSUE_TEMPLATE/* + - .github/PULL_REQUEST_TEMPLATE/* + - .gitignore + +"Category: Build System": + - meson.build + - meson_options.txt + - subprojects/* + +"Category: Documentation": + - docs/**/* + +"Category: Resources": + - resources/**/* + +"Category: Themes": + - data/colors/* + +"Category: Lua Core": + - data/core/**/* + +"Category: Fonts": + - data/fonts/* + +"Category: Plugins": + - data/plugins/* + +"Category: C Core": + - src/**/* diff --git a/.github/workflows/auto_labeler_pr.yml b/.github/workflows/auto_labeler_pr.yml new file mode 100644 index 00000000..48ea1eeb --- /dev/null +++ b/.github/workflows/auto_labeler_pr.yml @@ -0,0 +1,16 @@ +name: "Pull Request Labeler" +on: +- pull_request_target + +permissions: + pull-requests: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Apply Type Label + uses: actions/labeler@v3 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + sync-labels: "" # works around actions/labeler#104 diff --git a/README.md b/README.md index 71f856a1..e6cd18dc 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,57 @@ DESTDIR="$(pwd)/Lite XL.app" meson install --skip-subprojects -C build Please note that the package is relocatable to any prefix and the option prefix affects only the place where the application is actually installed. +## Installing Prebuilt + +Head over to [releases](https://github.com/lite-xl/lite-xl/releases) and download the version for your operating system. + +### Linux + +Unzip the file and `cd` into the `lite-xl` directory: + +```sh +tar -xzf +cd lite-xl +``` + +To run lite-xl without installing: +```sh +cd bin +./lite-xl +``` + +To install lite-xl copy files over into appropriate directories: + +```sh +mkdir -p $HOME/.local/bin && cp bin/lite-xl $HOME/.local/bin +cp -r share $HOME/.local +``` + +If `$HOME/.local/bin` is not in PATH: + +```sh +echo -e 'export PATH=$PATH:$HOME/.local/bin' >> $HOME/.bashrc +``` + +To get the icon to show up in app launcher: + +```sh +xdg-desktop-menu forceupdate +``` + +You may need to logout and login again to see icon in app launcher. + +To uninstall just run: + +```sh +rm -f $HOME/.local/bin/lite-xl +rm -rf $HOME/.local/share/icons/hicolor/scalable/apps/lite-xl.svg \ + $HOME/.local/share/applications/org.lite_xl.lite_xl.desktop \ + $HOME/.local/share/metainfo/org.lite_xl.lite_xl.appdata.xml \ + $HOME/.local/share/lite-xl +``` + + ## Contributing Any additional functionality that can be added through a plugin should be done diff --git a/changelog.md b/changelog.md index 57ab9646..c81c7dbe 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,31 @@ This files document the changes done in Lite XL for each release. +### 2.0.3 + +Replace periodic rescan of project folder with a notification based system using the +[dmon library](https://github.com/septag/dmon). Improves performance especially for +large project folders since the application no longer needs to rescan. +The application also reports immediatly any change in the project directory even +when the application is unfocused. + +Improved find-replace reverse and forward search. + +Fixed a bug in incremental syntax highlighting affecting documents with multiple-lines +comments or strings. + +The application now always shows the tabs in the documents' view even when a single +document is opened. Can be changed with the option `config.always_show_tabs`. + +Fix problem with numeric keypad function keys not properly working. + +Fix problem with pixel not correctly drawn at the window's right edge. + +Treat correctly and open network paths on Windows. + +Add some improvements for very slow network filesystems. + +Fix problem with python syntax highliting, contributed by @dflock. + ### 2.0.2 Fix problem project directory when starting the application from Launcher on macOS. diff --git a/data/core/command.lua b/data/core/command.lua index 7915e16d..2cf851da 100644 --- a/data/core/command.lua +++ b/data/core/command.lua @@ -41,11 +41,14 @@ function command.get_all_valid() return res end +function command.is_valid(name, ...) + return command.map[name] and command.map[name].predicate(...) +end -local function perform(name) +local function perform(name, ...) local cmd = command.map[name] - if cmd and cmd.predicate() then - cmd.perform() + if cmd and cmd.predicate(...) then + cmd.perform(...) return true end return false diff --git a/data/core/commands/core.lua b/data/core/commands/core.lua index 432ded89..ad0d4b10 100644 --- a/data/core/commands/core.lua +++ b/data/core/commands/core.lua @@ -6,10 +6,12 @@ local LogView = require "core.logview" local fullscreen = false +local restore_title_view = false local function suggest_directory(text) text = common.home_expand(text) - return common.home_encode_list(text == "" and core.recent_projects or common.dir_path_suggest(text)) + return common.home_encode_list((text == "" or text == common.home_expand(common.dirname(core.project_dir))) + and core.recent_projects or common.dir_path_suggest(text)) end command.add(nil, { @@ -27,9 +29,12 @@ command.add(nil, { ["core:toggle-fullscreen"] = function() fullscreen = not fullscreen + if fullscreen then + restore_title_view = core.title_view.visible + end system.set_window_mode(fullscreen and "fullscreen" or "normal") - core.show_title_bar(not fullscreen) - core.title_view:configure_hit_test(not fullscreen) + core.show_title_bar(not fullscreen and restore_title_view) + core.title_view:configure_hit_test(not fullscreen and restore_title_view) end, ["core:reload-module"] = function() @@ -66,8 +71,8 @@ command.add(nil, { end, ["core:find-file"] = function() - if core.project_files_limit then - return command.perform "core:open-file" + if not core.project_files_number() then + return command.perform "core:open-file" end local files = {} for dir, item in core.get_project_files() do @@ -149,7 +154,7 @@ command.add(nil, { ["core:change-project-folder"] = function() local dirname = common.dirname(core.project_dir) if dirname then - core.command_view:set_text(common.home_encode(dirname) .. PATHSEP) + core.command_view:set_text(common.home_encode(dirname)) end core.command_view:enter("Change Project Folder", function(text, item) text = system.absolute_path(common.home_expand(item and item.text or text)) @@ -166,7 +171,7 @@ command.add(nil, { ["core:open-project-folder"] = function() local dirname = common.dirname(core.project_dir) if dirname then - core.command_view:set_text(common.home_encode(dirname) .. PATHSEP) + core.command_view:set_text(common.home_encode(dirname)) end core.command_view:enter("Open Project", function(text, item) text = common.home_expand(item and item.text or text) @@ -191,8 +196,6 @@ command.add(nil, { return end core.add_project_directory(system.absolute_path(text)) - -- TODO: add the name of directory to prioritize - core.reschedule_project_scan() end, suggest_directory) end, diff --git a/data/core/commands/doc.lua b/data/core/commands/doc.lua index b8ce2cb5..c3063f97 100644 --- a/data/core/commands/doc.lua +++ b/data/core/commands/doc.lua @@ -16,14 +16,6 @@ local function 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 doc_multiline_selections(sort) local iter, state, idx, line1, col1, line2, col2 = doc():get_selections(sort) return function() @@ -82,6 +74,31 @@ local function split_cursor(direction) core.blink_reset() end +local function set_cursor(x, y, snap_type) + local line, col = dv():resolve_screen_position(x, y) + doc():set_selection(line, col, line, col) + if snap_type == "word" or snap_type == "lines" then + command.perform("doc:select-" .. snap_type) + end + dv().mouse_selecting = { line, col, snap_type } + core.blink_reset() +end + +local selection_commands = { + ["doc:cut"] = function() + cut_or_copy(true) + end, + + ["doc:copy"] = function() + cut_or_copy(false) + end, + + ["doc:select-none"] = function() + local line, col = doc():get_selection() + doc():set_selection(line, col) + end +} + local commands = { ["doc:undo"] = function() doc():undo() @@ -91,14 +108,6 @@ local commands = { doc():redo() end, - ["doc:cut"] = function() - cut_or_copy(true) - end, - - ["doc:copy"] = function() - cut_or_copy(false) - end, - ["doc:paste"] = function() local clipboard = system.get_clipboard() -- If the clipboard has changed since our last look, use that instead @@ -147,11 +156,12 @@ local commands = { end, ["doc:backspace"] = function() + local _, indent_size = doc():get_indent_info() for idx, line1, col1, line2, col2 in doc():get_selections() do if line1 == line2 and col1 == col2 then local text = doc():get_text(line1, 1, line1, col1) - if #text >= config.indent_size and text:find("^ *$") then - doc():delete_to_cursor(idx, 0, -config.indent_size) + if #text >= indent_size and text:find("^ *$") then + doc():delete_to_cursor(idx, 0, -indent_size) return end end @@ -163,11 +173,6 @@ local commands = { 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() for idx, line1, _, line2 in doc():get_selections(true) do append_line_if_last_line(line2) @@ -261,7 +266,7 @@ local commands = { ["doc:toggle-line-comments"] = function() local comment = doc().syntax.comment if not comment then return end - local indentation = get_indent_string() + local indentation = doc():get_indent_string() local comment_text = comment .. " " for idx, line1, _, line2 in doc_multiline_selections(true) do local uncomment = true @@ -388,6 +393,30 @@ local commands = { os.remove(filename) core.log("Removed \"%s\"", filename) end, + + ["doc:select-to-cursor"] = function(x, y, clicks) + local line1, col1 = select(3, doc():get_selection()) + local line2, col2 = dv():resolve_screen_position(x, y) + dv().mouse_selecting = { line1, col1, nil } + doc():set_selection(line2, col2, line1, col1) + end, + + ["doc:set-cursor"] = function(x, y) + set_cursor(x, y, "set") + end, + + ["doc:set-cursor-word"] = function(x, y) + set_cursor(x, y, "word") + end, + + ["doc:set-cursor-line"] = function(x, y, clicks) + set_cursor(x, y, "lines") + end, + + ["doc:split-cursor"] = function(x, y, clicks) + local line, col = dv():resolve_screen_position(x, y) + doc():add_selection(line, col, line, col) + end, ["doc:create-cursor-previous-line"] = function() split_cursor(-1) @@ -447,3 +476,6 @@ commands["doc:move-to-next-char"] = function() end command.add("core.docview", commands) +command.add(function() + return core.active_view:is(DocView) and core.active_view.doc:has_any_selection() +end ,selection_commands) diff --git a/data/core/commands/findreplace.lua b/data/core/commands/findreplace.lua index f8e8e45a..0801b745 100644 --- a/data/core/commands/findreplace.lua +++ b/data/core/commands/findreplace.lua @@ -7,15 +7,15 @@ local DocView = require "core.docview" local CommandView = require "core.commandview" local StatusView = require "core.statusview" -local max_last_finds = 50 -local last_finds, last_view, last_fn, last_text, last_sel +local last_view, last_fn, last_text, last_sel local case_sensitive = config.find_case_sensitive or false local find_regex = config.find_regex or false local found_expression local function doc() - return core.active_view:is(DocView) and core.active_view.doc or last_view.doc + local is_DocView = core.active_view:is(DocView) and not core.active_view:is(CommandView) + return is_DocView and core.active_view.doc or (last_view and last_view.doc) end local function get_find_tooltip() @@ -37,7 +37,7 @@ local function update_preview(sel, search_fn, text) last_view:scroll_to_line(line2, true) found_expression = true else - last_view.doc:set_selection(unpack(sel)) + last_view.doc:set_selection(table.unpack(sel)) found_expression = false end end @@ -53,8 +53,8 @@ end local function find(label, search_fn) - last_view, last_sel, last_finds = core.active_view, - { core.active_view.doc:get_selection() }, {} + last_view, last_sel = core.active_view, + { core.active_view.doc:get_selection() } local text = last_view.doc:get_text(unpack(last_sel)) found_expression = false @@ -69,8 +69,8 @@ local function find(label, search_fn) last_fn, last_text = search_fn, text else core.error("Couldn't find %q", text) - last_view.doc:set_selection(unpack(last_sel)) - last_view:scroll_to_make_visible(unpack(last_sel)) + last_view.doc:set_selection(table.unpack(last_sel)) + last_view:scroll_to_make_visible(table.unpack(last_sel)) end end, function(text) update_preview(last_sel, search_fn, text) @@ -79,8 +79,8 @@ local function find(label, search_fn) end, function(explicit) core.status_view:remove_tooltip() if explicit then - last_view.doc:set_selection(unpack(last_sel)) - last_view:scroll_to_make_visible(unpack(last_sel)) + last_view.doc:set_selection(table.unpack(last_sel)) + last_view:scroll_to_make_visible(table.unpack(last_sel)) end end) end @@ -117,7 +117,7 @@ local function has_selection() end local function has_unique_selection() - if not core.active_view:is(DocView) then return false end + if not doc() then return false end local text = nil for idx, line1, col1, line2, col2 in doc():get_selections(true, true) do if line1 == line2 and col1 == col2 then return false end @@ -142,7 +142,7 @@ local function is_in_any_selection(line, col) return false end -local function select_next(all) +local function select_add_next(all) local il1, ic1 = doc():get_selection(true) for idx, l1, c1, l2, c2 in doc():get_selections(true, true) do local text = doc():get_text(l1, c1, l2, c2) @@ -161,21 +161,28 @@ local function select_next(all) end end -command.add(has_unique_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 function select_next(reverse) + local l1, c1, l2, c2 = doc():get_selection(true) + local text = doc():get_text(l1, c1, l2, c2) + if reverse then + l1, c1, l2, c2 = search.find(doc(), l1, c1, text, { wrap = true, reverse = true }) + else l1, c1, l2, c2 = search.find(doc(), l2, c2, text, { wrap = true }) - if l2 then doc():set_selection(l2, c2, l1, c1) end - end, - ["find-replace:select-add-next"] = function() select_next(false) end, - ["find-replace:select-add-all"] = function() select_next(true) end + end + if l2 then doc():set_selection(l2, c2, l1, c1) end +end + +command.add(has_unique_selection, { + ["find-replace:select-next"] = select_next, + ["find-replace:select-previous"] = function() select_next(true) end, + ["find-replace:select-add-next"] = select_add_next, + ["find-replace:select-add-all"] = function() select_add_next(true) end }) command.add("core.docview", { ["find-replace:find"] = function() - find("Find Text", function(doc, line, col, text, case_sensitive, find_regex) - local opt = { wrap = true, no_case = not case_sensitive, regex = find_regex } + find("Find Text", function(doc, line, col, text, case_sensitive, find_regex, find_reverse) + local opt = { wrap = true, no_case = not case_sensitive, regex = find_regex, reverse = find_reverse } return search.find(doc, line, col, text, opt) end) end, @@ -221,29 +228,29 @@ command.add(valid_for_finding, { core.error("No find to continue from") else local sl1, sc1, sl2, sc2 = doc():get_selection(true) - local line1, col1, line2, col2 = last_fn(doc(), sl1, sc2, last_text, case_sensitive, find_regex) + local line1, col1, line2, col2 = last_fn(doc(), sl1, sc2, last_text, case_sensitive, find_regex, false) if line1 then - if last_view.doc ~= doc() then - last_finds = {} - end - if #last_finds >= max_last_finds then - table.remove(last_finds, 1) - end - table.insert(last_finds, { sl1, sc1, sl2, sc2 }) doc():set_selection(line2, col2, line1, col1) last_view:scroll_to_line(line2, true) + else + core.error("Couldn't find %q", last_text) end end end, ["find-replace:previous-find"] = function() - local sel = table.remove(last_finds) - if not sel or doc() ~= last_view.doc then - core.error("No previous finds") - return + if not last_fn then + core.error("No find to continue from") + else + local sl1, sc1, sl2, sc2 = doc():get_selection(true) + local line1, col1, line2, col2 = last_fn(doc(), sl1, sc1, last_text, case_sensitive, find_regex, true) + if line1 then + doc():set_selection(line2, col2, line1, col1) + last_view:scroll_to_line(line2, true) + else + core.error("Couldn't find %q", last_text) + end end - doc():set_selection(table.unpack(sel)) - last_view:scroll_to_line(sel[3], true) end, }) diff --git a/data/core/commands/root.lua b/data/core/commands/root.lua index e41c723d..5bf18390 100644 --- a/data/core/commands/root.lua +++ b/data/core/commands/root.lua @@ -3,6 +3,7 @@ local style = require "core.style" local DocView = require "core.docview" local command = require "core.command" local common = require "core.common" +local config = require "core.config" local t = { @@ -63,7 +64,7 @@ local t = { 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) @@ -76,7 +77,7 @@ local t = { 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, + end } @@ -112,7 +113,8 @@ for _, dir in ipairs { "left", "right", "up", "down" } do 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 + local sx, sy = node:get_locked_size() + if not sx and not sy then core.set_active_view(node.active_view) end end @@ -120,5 +122,17 @@ end command.add(function() local node = core.root_view:get_active_node() - return not node:get_locked_size() + local sx, sy = node:get_locked_size() + return not sx and not sy end, t) + +command.add(nil, { + ["root:scroll"] = function(delta) + local view = (core.root_view.overlapping_node and core.root_view.overlapping_node.active_view) or core.active_view + if view and view.scrollable then + view.scroll.to.y = view.scroll.to.y + delta * -config.mouse_wheel_scroll + return true + end + return false + end +}) diff --git a/data/core/common.lua b/data/core/common.lua index 1a1b22cd..ab21b758 100644 --- a/data/core/common.lua +++ b/data/core/common.lua @@ -1,8 +1,8 @@ local common = {} -function common.is_utf8_cont(char) - local byte = char:byte() +function common.is_utf8_cont(s, offset) + local byte = s:byte(offset or 1) return byte >= 0x80 and byte < 0xc0 end @@ -280,24 +280,61 @@ local function split_on_slash(s, sep_pattern) end -function common.normalize_path(filename) +-- The filename argument given to the function is supposed to +-- come from system.absolute_path and as such should be an +-- absolute path without . or .. elements. +-- This function exists because on Windows the drive letter returned +-- by system.absolute_path is sometimes with a lower case and sometimes +-- with an upper case to we normalize to upper case. +function common.normalize_volume(filename) if not filename then return end + if PATHSEP == '\\' then + local drive, rem = filename:match('^([a-zA-Z]:\\)(.*)') + if drive then + return drive:upper() .. rem + end + end + return filename +end + + +function common.normalize_path(filename) + if not filename then return end + local volume if PATHSEP == '\\' then filename = filename:gsub('[/\\]', '\\') - local drive, rem = filename:match('^([a-zA-Z])(:.*)') - filename = drive and drive:upper() .. rem or filename + local drive, rem = filename:match('^([a-zA-Z]:\\)(.*)') + if drive then + volume, filename = drive:upper(), rem + else + drive, rem = filename:match('^(\\\\[^\\]+\\[^\\]+\\)(.*)') + if drive then + volume, filename = drive, rem + end + end + else + local relpath = filename:match('^/(.+)') + if relpath then + volume, filename = "/", relpath + end end local parts = split_on_slash(filename, PATHSEP) local accu = {} for _, part in ipairs(parts) do - if part == '..' and #accu > 0 and accu[#accu] ~= ".." then - table.remove(accu) + if part == '..' then + if #accu > 0 and accu[#accu] ~= ".." then + table.remove(accu) + elseif volume then + error("invalid path " .. volume .. filename) + else + table.insert(accu, part) + end elseif part ~= '.' then table.insert(accu, part) end end local npath = table.concat(accu, PATHSEP) - return npath == "" and PATHSEP or npath + return (volume or "") .. (npath == "" and PATHSEP or npath) end diff --git a/data/core/config.lua b/data/core/config.lua index 7cf23925..71e83994 100644 --- a/data/core/config.lua +++ b/data/core/config.lua @@ -1,6 +1,5 @@ local config = {} -config.project_scan_rate = 5 config.fps = 60 config.max_log_items = 80 config.message_timeout = 5 @@ -12,8 +11,8 @@ config.symbol_pattern = "[%a_][%w_]*" config.non_word_chars = " \t\n/\\()\"':,.;<>~!@#$%^&*|+=[]{}`?-" config.undo_merge_timeout = 0.3 config.max_undos = 10000 -config.max_tabs = 10 -config.always_show_tabs = false +config.max_tabs = 8 +config.always_show_tabs = true config.highlight_current_line = true config.line_height = 1.2 config.indent_size = 2 @@ -28,6 +27,7 @@ config.disable_blink = false config.draw_whitespace = false config.borderless = false config.tab_close_button = true +config.max_clicks = 3 -- Disable plugin loading setting to false the config entry -- of the same name. diff --git a/data/core/contextmenu.lua b/data/core/contextmenu.lua index 36247597..9db35bb3 100644 --- a/data/core/contextmenu.lua +++ b/data/core/contextmenu.lua @@ -49,7 +49,7 @@ function ContextMenu:register(predicate, items) local width, height = 0, 0 --precalculate the size of context menu for i, item in ipairs(items) do if item ~= DIVIDER then - item.info = keymap.reverse_map[item.command] + item.info = keymap.get_binding(item.command) end local lw, lh = get_item_size(item) width = math.max(width, lw) @@ -66,9 +66,13 @@ function ContextMenu:show(x, y) for _, items in ipairs(self.itemset) do if items.predicate(x, y) then items_list.width = math.max(items_list.width, items.items.width) - items_list.height = items_list.height + items.items.height + items_list.height = items_list.height for _, subitems in ipairs(items.items) do - table.insert(items_list, subitems) + if not subitems.command or command.is_valid(subitems.command) then + local lw, lh = get_item_size(subitems) + items_list.height = items_list.height + lh + table.insert(items_list, subitems) + end end end end diff --git a/data/core/doc/highlighter.lua b/data/core/doc/highlighter.lua index 4cb703da..9ba7b634 100644 --- a/data/core/doc/highlighter.lua +++ b/data/core/doc/highlighter.lua @@ -1,4 +1,5 @@ local core = require "core" +local common = require "core.common" local config = require "core.config" local tokenizer = require "core.tokenizer" local Object = require "core.object" @@ -40,6 +41,13 @@ end function Highlighter:reset() self.lines = {} + self:soft_reset() +end + +function Highlighter:soft_reset() + for i=1,#self.lines do + self.lines[i] = false + end self.first_invalid_line = 1 self.max_wanted_line = 0 end @@ -51,16 +59,16 @@ end function Highlighter:insert_notify(line, n) self:invalidate(line) + local blanks = { } for i = 1, n do - table.insert(self.lines, line, nil) + blanks[i] = false end + common.splice(self.lines, line, 0, blanks) end function Highlighter:remove_notify(line, n) self:invalidate(line) - for i = 1, n do - table.remove(self.lines, line) - end + common.splice(self.lines, line, n) end diff --git a/data/core/doc/init.lua b/data/core/doc/init.lua index 920e2669..f324b6d3 100644 --- a/data/core/doc/init.lua +++ b/data/core/doc/init.lua @@ -47,7 +47,7 @@ function Doc:reset_syntax() local syn = syntax.get(self.filename or "", header) if self.syntax ~= syn then self.syntax = syn - self.highlighter:reset() + self.highlighter:soft_reset() end end @@ -62,12 +62,15 @@ function Doc:load(filename) local fp = assert( io.open(filename, "rb") ) self:reset() self.lines = {} + local i = 1 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") + self.highlighter.lines[i] = false + i = i + 1 end if #self.lines == 0 then table.insert(self.lines, "\n") @@ -115,6 +118,14 @@ function Doc:clean() end +function Doc:get_indent_info() + if not self.indent_info then return config.tab_type, config.indent_size, false end + return self.indent_info.type or config.tab_type, + self.indent_info.size or config.indent_size, + self.indent_info.confirmed +end + + function Doc:get_change_id() return self.undo_stack.idx end @@ -146,6 +157,13 @@ function Doc:has_selection() return line1 ~= line2 or col1 ~= col2 end +function Doc:has_any_selection() + for idx, line1, col1, line2, col2 in self:get_selections() do + if line1 ~= line2 or col1 ~= col2 then return true end + end + return false +end + function Doc:sanitize_selection() for idx, line1, col1, line2, col2 in self:get_selections() do self:set_selections(idx, line1, col1, line2, col2) @@ -202,9 +220,9 @@ local function selection_iterator(invariant, idx) local target = invariant[3] and (idx*4 - 7) or (idx*4 + 1) if target > #invariant[1] or target <= 0 or (type(invariant[3]) == "number" and invariant[3] ~= idx - 1) then return end if invariant[2] then - return idx+(invariant[3] and -1 or 1), sort_positions(unpack(invariant[1], target, target+4)) + return idx+(invariant[3] and -1 or 1), sort_positions(table.unpack(invariant[1], target, target+4)) else - return idx+(invariant[3] and -1 or 1), unpack(invariant[1], target, target+4) + return idx+(invariant[3] and -1 or 1), table.unpack(invariant[1], target, target+4) end end @@ -305,7 +323,8 @@ local function pop_undo(self, undo_stack, redo_stack, modified) 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.selections = { unpack(cmd) } + self.selections = { table.unpack(cmd) } + self:sanitize_selection() end modified = modified or (cmd.type ~= "selection") @@ -348,7 +367,7 @@ function Doc:raw_insert(line, col, text, undo_stack, time) -- push undo local line2, col2 = self:position_offset(line, col, #text) - push_undo(undo_stack, time, "selection", unpack(self.selections)) + push_undo(undo_stack, time, "selection", table.unpack(self.selections)) push_undo(undo_stack, time, "remove", line, col, line2, col2) -- update highlighter and assure selection is in bounds @@ -360,7 +379,7 @@ 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", unpack(self.selections)) + push_undo(undo_stack, time, "selection", table.unpack(self.selections)) push_undo(undo_stack, time, "insert", line1, col1, text) -- get line content before/after removed text @@ -486,19 +505,21 @@ end function Doc:select_to(...) return self:select_to_cursor(nil, ...) end -local function get_indent_string() - if config.tab_type == "hard" then +function Doc:get_indent_string() + local indent_type, indent_size = self:get_indent_info() + if indent_type == "hard" then return "\t" end - return string.rep(" ", config.indent_size) + return string.rep(" ", indent_size) end -- returns the size of the original indent, and the indent -- in your config format, rounded either up or down -local function get_line_indent(line, rnd_up) +function Doc:get_line_indent(line, rnd_up) local _, e = line:find("^[ \t]+") - local soft_tab = string.rep(" ", config.indent_size) - if config.tab_type == "hard" then + local indent_type, indent_size = self:get_indent_info() + local soft_tab = string.rep(" ", indent_size) + if indent_type == "hard" then local indent = e and line:sub(1, e):gsub(soft_tab, "\t") or "" return e, indent:gsub(" +", rnd_up and "\t" or "") else @@ -520,14 +541,14 @@ end -- * if you are unindenting, the cursor will jump to the start of the line, -- and remove the appropriate amount of spaces (or a tab). function Doc:indent_text(unindent, line1, col1, line2, col2) - local text = get_indent_string() + local text = self:get_indent_string() local _, se = self.lines[line1]:find("^[ \t]+") local in_beginning_whitespace = col1 == 1 or (se and col1 <= se + 1) local has_selection = line1 ~= line2 or col1 ~= col2 if unindent or has_selection or in_beginning_whitespace then local l1d, l2d = #self.lines[line1], #self.lines[line2] for line = line1, line2 do - local e, rnded = get_line_indent(self.lines[line], unindent) + local e, rnded = self:get_line_indent(self.lines[line], unindent) self:remove(line, 1, line, (e or 0) + 1) self:insert(line, 1, unindent and rnded:sub(1, #rnded - #text) or rnded .. text) diff --git a/data/core/doc/search.lua b/data/core/doc/search.lua index 04090673..8395769a 100644 --- a/data/core/doc/search.lua +++ b/data/core/doc/search.lua @@ -22,37 +22,62 @@ local function init_args(doc, line, col, text, opt) return doc, line, col, text, opt end +-- This function is needed to uniform the behavior of +-- `regex:cmatch` and `string.find`. +local function regex_func(text, re, index, _) + local s, e = re:cmatch(text, index) + return s, e and e - 1 +end + +local function rfind(func, text, pattern, index, plain) + local s, e = func(text, pattern, 1, plain) + local last_s, last_e + if index < 0 then index = #text - index + 1 end + while e and e <= index do + last_s, last_e = s, e + s, e = func(text, pattern, s + 1, plain) + end + return last_s, last_e +end + function search.find(doc, line, col, text, opt) doc, line, col, text, opt = init_args(doc, line, col, text, opt) - - local re + local plain = not opt.pattern + local pattern = text + local search_func = string.find if opt.regex then - re = regex.compile(text, opt.no_case and "i" or "") + pattern = regex.compile(text, opt.no_case and "i" or "") + search_func = regex_func end - for line = line, #doc.lines do + local start, finish, step = line, #doc.lines, 1 + if opt.reverse then + start, finish, step = line, 1, -1 + end + for line = start, finish, step do local line_text = doc.lines[line] - if opt.regex then - local s, e = re:cmatch(line_text, col) - if s then - return line, s, line, e - end - col = 1 - else - if opt.no_case then - line_text = line_text:lower() - end - local s, e = line_text:find(text, col, true) - if s then - return line, s, line, e + 1 - end - col = 1 + if opt.no_case and not opt.regex then + line_text = line_text:lower() end + local s, e + if opt.reverse then + s, e = rfind(search_func, line_text, pattern, col - 1, plain) + else + s, e = search_func(line_text, pattern, col, plain) + end + if s then + return line, s, line, e + 1 + end + col = opt.reverse and -1 or 1 end if opt.wrap then - opt = { no_case = opt.no_case, regex = opt.regex } - return search.find(doc, 1, 1, text, opt) + opt = { no_case = opt.no_case, regex = opt.regex, reverse = opt.reverse } + if opt.reverse then + return search.find(doc, #doc.lines, #doc.lines[#doc.lines], text, opt) + else + return search.find(doc, 1, 1, text, opt) + end end end diff --git a/data/core/docview.lua b/data/core/docview.lua index 4e95c359..a4587670 100644 --- a/data/core/docview.lua +++ b/data/core/docview.lua @@ -225,51 +225,6 @@ function DocView:scroll_to_make_visible(line, col) 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 % 4 == 2 then - line1, col1 = translate.start_of_word(doc, line1, col1) - line2, col2 = translate.end_of_word(doc, line2, col2) - elseif clicks % 4 == 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 % 2 == 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) - if keymap.modkeys["ctrl"] then - self.doc:add_selection(mouse_selection(self.doc, clicks, line, col, line, col)) - else - self.doc:set_selection(mouse_selection(self.doc, clicks, line, col, line, col)) - end - self.mouse_selecting = { line, col, clicks = clicks } - end - core.blink_reset() -end - - function DocView:on_mouse_moved(x, y, ...) DocView.super.on_mouse_moved(self, x, y, ...) @@ -281,8 +236,7 @@ function DocView:on_mouse_moved(x, y, ...) 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 + local l2, c2, snap_type = table.unpack(self.mouse_selecting) if keymap.modkeys["ctrl"] then if l1 > l2 then l1, l2 = l2, l1 end self.doc.selections = { } @@ -290,12 +244,33 @@ function DocView:on_mouse_moved(x, y, ...) self.doc:set_selections(i - l1 + 1, i, math.min(c1, #self.doc.lines[i]), i, math.min(c2, #self.doc.lines[i])) end else - self.doc:set_selection(mouse_selection(self.doc, clicks, l1, c1, l2, c2)) + if snap_type then + l1, c1, l2, c2 = self:mouse_selection(self.doc, snap_type, l1, c1, l2, c2) + end + self.doc:set_selection(l1, c1, l2, c2) end end end +function DocView:mouse_selection(doc, snap_type, 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 snap_type == "word" then + line1, col1 = translate.start_of_word(doc, line1, col1) + line2, col2 = translate.end_of_word(doc, line2, col2) + elseif snap_type == "lines" then + col1, col2 = 1, math.huge + end + if swap then + return line2, col2, line1, col1 + end + return line1, col1, line2, col2 +end + + function DocView:on_mouse_released(button) DocView.super.on_mouse_released(self, button) self.mouse_selecting = nil @@ -354,6 +329,18 @@ function DocView:draw_caret(x, y) end function DocView:draw_line_body(idx, x, y) + -- draw highlight if any selection ends on this line + local draw_highlight = false + for lidx, line1, col1, line2, col2 in self.doc:get_selections(false) do + if line1 == idx then + draw_highlight = true + break + end + end + if draw_highlight and config.highlight_current_line and core.active_view == self then + self:draw_line_highlight(x + self.scroll.x, y) + end + -- draw selection if it overlaps this line for lidx, line1, col1, line2, col2 in self.doc:get_selections(true) do if idx >= line1 and idx <= line2 then @@ -368,15 +355,6 @@ function DocView:draw_line_body(idx, x, y) end end end - local draw_highlight = nil - for lidx, line1, col1, line2, col2 in self.doc:get_selections(true) do - -- draw line highlight if caret is on this line - if draw_highlight ~= false and config.highlight_current_line - and line1 == idx and core.active_view == self then - draw_highlight = (line1 == line2 and col1 == col2) - end - end - if draw_highlight then self:draw_line_highlight(x + self.scroll.x, y) end -- draw line's text self:draw_line_text(idx, x, y) @@ -417,8 +395,8 @@ end function DocView:draw() self:draw_background(style.background) - - self:get_font():set_tab_size(config.indent_size) + local _, indent_size = self.doc:get_indent_info() + self:get_font():set_tab_size(indent_size) local minline, maxline = self:get_visible_line_range() local lh = self:get_line_height() @@ -432,7 +410,9 @@ function DocView:draw() local pos = self.position x, y = self:get_line_screen_position(minline) - core.push_clip_rect(pos.x + gw, pos.y, self.size.x, self.size.y) + -- the clip below ensure we don't write on the gutter region. On the + -- right side it is redundant with the Node's clip. + core.push_clip_rect(pos.x + gw, pos.y, self.size.x - gw, self.size.y) for i = minline, maxline do self:draw_line_body(i, x, y) y = y + lh diff --git a/data/core/init.lua b/data/core/init.lua index 6ef0ab7e..aadccc66 100644 --- a/data/core/init.lua +++ b/data/core/init.lua @@ -36,7 +36,7 @@ end local function update_recents_project(action, dir_path_abs) - local dirname = common.normalize_path(dir_path_abs) + local dirname = common.normalize_volume(dir_path_abs) if not dirname then return end local recents = core.recent_projects local n = #recents @@ -52,23 +52,13 @@ local function update_recents_project(action, dir_path_abs) end -function core.reschedule_project_scan() - if core.project_scan_thread_id then - core.threads[core.project_scan_thread_id].wake = 0 - end -end - - function core.set_project_dir(new_dir, change_project_fn) local chdir_ok = pcall(system.chdir, new_dir) if chdir_ok then if change_project_fn then change_project_fn() end - core.project_dir = common.normalize_path(new_dir) + core.project_dir = common.normalize_volume(new_dir) core.project_directories = {} core.add_project_directory(new_dir) - core.project_files = {} - core.project_files_limit = false - core.reschedule_project_scan() return true end return false @@ -102,6 +92,29 @@ local function compare_file(a, b) return a.filename < b.filename end + +-- compute a file's info entry completed with "filename" to be used +-- in project scan or falsy if it shouldn't appear in the list. +local function get_project_file_info(root, file) + local info = system.get_file_info(root .. file) + if info then + info.filename = strip_leading_path(file) + return (info.size < config.file_size_limit * 1e6 and + not common.match_pattern(common.basename(info.filename), config.ignore_files) + and info) + end +end + + +-- Predicate function to inhibit directory recursion in get_directory_files +-- based on a time limit and the number of files. +local function timed_max_files_pred(dir, filename, entries_count, t_elapsed) + local n_limit = entries_count <= config.max_project_files + local t_limit = t_elapsed < 20 / config.fps + return n_limit and t_limit and core.project_subdir_is_shown(dir, filename) +end + + -- "root" will by an absolute path without trailing '/' -- "path" will be a path starting with '/' and without trailing '/' -- or the empty string. @@ -110,34 +123,31 @@ end -- When recursing "root" will always be the same, only "path" will change. -- Returns a list of file "items". In eash item the "filename" will be the -- complete file path relative to "root" *without* the trailing '/'. -local function get_directory_files(root, path, t, recursive, begin_hook) +local function get_directory_files(dir, root, path, t, entries_count, recurse_pred, begin_hook) if begin_hook then begin_hook() end - local size_limit = config.file_size_limit * 10e5 + local t0 = system.get_time() local all = system.list_dir(root .. path) or {} + local t_elapsed = system.get_time() - t0 local dirs, files = {}, {} - local entries_count = 0 - local max_entries = config.max_project_files for _, file in ipairs(all) do - if not common.match_pattern(file, config.ignore_files) then - local file = path .. PATHSEP .. file - local info = system.get_file_info(root .. file) - if info and info.size < size_limit then - info.filename = strip_leading_path(file) - table.insert(info.type == "dir" and dirs or files, info) - entries_count = entries_count + 1 - if recursive and entries_count > max_entries then return nil, entries_count end - end + local info = get_project_file_info(root, path .. PATHSEP .. file) + if info then + table.insert(info.type == "dir" and dirs or files, info) + entries_count = entries_count + 1 end end + local recurse_complete = true table.sort(dirs, compare_file) for _, f in ipairs(dirs) do table.insert(t, f) - if recursive and entries_count <= max_entries then - local subdir_t, subdir_count = get_directory_files(root, PATHSEP .. f.filename, t, recursive) - entries_count = entries_count + subdir_count - f.scanned = true + if recurse_pred(dir, f.filename, entries_count, t_elapsed) then + local _, complete, n = get_directory_files(dir, root, PATHSEP .. f.filename, t, entries_count, recurse_pred, begin_hook) + recurse_complete = recurse_complete and complete + entries_count = n + else + recurse_complete = false end end @@ -146,135 +156,319 @@ local function get_directory_files(root, path, t, recursive, begin_hook) table.insert(t, f) end - return t, entries_count + return t, recurse_complete, entries_count end -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 - while true do - -- get project files and replace previous table if the new table is - -- different - local i = 1 - while not core.project_files_limit and i <= #core.project_directories do - local dir = core.project_directories[i] - local t, entries_count = get_directory_files(dir.name, "", {}, true) - if diff_files(dir.files, t) then - if entries_count > config.max_project_files then - core.project_files_limit = true - core.status_view:show_message("!", style.accent, - "Too many files in project directory: stopped reading at ".. - config.max_project_files.." files. For more information see ".. - "usage.md at github.com/franko/lite-xl." - ) - end - dir.files = t - core.redraw = true - end - if dir.name == core.project_dir then - core.project_files = dir.files - end - i = i + 1 +function core.project_subdir_set_show(dir, filename, show) + dir.shown_subdir[filename] = show + if dir.files_limit and PLATFORM == "Linux" then + local fullpath = dir.name .. PATHSEP .. filename + local watch_fn = show and system.watch_dir_add or system.watch_dir_rm + local success = watch_fn(dir.watch_id, fullpath) + if not success then + core.log("Internal warning: error calling system.watch_dir_%s", show and "add" or "rm") end - - -- wait for next scan - coroutine.yield(config.project_scan_rate) end end -function core.is_project_folder(dirname) - for _, dir in ipairs(core.project_directories) do - if dir.name == dirname then - return true - end - end - return false +function core.project_subdir_is_shown(dir, filename) + return not dir.files_limit or dir.shown_subdir[filename] end -function core.scan_project_folder(dirname, filename) - for _, dir in ipairs(core.project_directories) do - if dir.name == dirname then - for i, file in ipairs(dir.files) do - local file = dir.files[i] - if file.filename == filename then - if file.scanned then return end - local new_files = get_directory_files(dirname, PATHSEP .. filename, {}) - for j, new_file in ipairs(new_files) do - table.insert(dir.files, i + j, new_file) - end - file.scanned = true - return +local function show_max_files_warning(dir) + local message = dir.slow_filesystem and + "Filesystem is too slow: project files will not be indexed." or + "Too many files in project directory: stopped reading at ".. + config.max_project_files.." files. For more information see ".. + "usage.md at github.com/franko/lite-xl." + core.status_view:show_message("!", style.accent, message) +end + + +local function file_search(files, info) + local filename, type = info.filename, info.type + local inf, sup = 1, #files + while sup - inf > 8 do + local curr = math.floor((inf + sup) / 2) + if system.path_compare(filename, type, files[curr].filename, files[curr].type) then + sup = curr - 1 + else + inf = curr + end + end + while inf <= sup and not system.path_compare(filename, type, files[inf].filename, files[inf].type) do + if files[inf].filename == filename then + return inf, true + end + inf = inf + 1 + end + return inf, false +end + + +local function project_scan_add_entry(dir, fileinfo) + local index, match = file_search(dir.files, fileinfo) + if not match then + table.insert(dir.files, index, fileinfo) + dir.is_dirty = true + end +end + + +local function files_info_equal(a, b) + return a.filename == b.filename and a.type == b.type +end + +-- for "a" inclusive from i1 + 1 and i1 + n +local function files_list_match(a, i1, n, b) + if n ~= #b then return false end + for i = 1, n do + if not files_info_equal(a[i1 + i], b[i]) then + return false + end + end + return true +end + +-- arguments like for files_list_match +local function files_list_replace(as, i1, n, bs) + local m = #bs + local i, j = 1, 1 + while i <= m or i <= n do + local a, b = as[i1 + i], bs[j] + if i > n or (j <= m and not files_info_equal(a, b) and + not system.path_compare(a.filename, a.type, b.filename, b.type)) + then + table.insert(as, i1 + i, b) + i, j, n = i + 1, j + 1, n + 1 + elseif j > m or system.path_compare(a.filename, a.type, b.filename, b.type) then + table.remove(as, i1 + i) + n = n - 1 + else + i, j = i + 1, j + 1 + end + end +end + +local function project_subdir_bounds(dir, filename) + local index, n = 0, #dir.files + for i, file in ipairs(dir.files) do + local file = dir.files[i] + if file.filename == filename then + index, n = i, #dir.files - i + for j = 1, #dir.files - i do + if not common.path_belongs_to(dir.files[i + j].filename, filename) then + n = j - 1 + break end end + return index, n, file end end end +local function rescan_project_subdir(dir, filename_rooted) + local new_files = get_directory_files(dir, dir.name, filename_rooted, {}, 0, core.project_subdir_is_shown, coroutine.yield) + local index, n = 0, #dir.files + if filename_rooted ~= "" then + local filename = strip_leading_path(filename_rooted) + index, n = project_subdir_bounds(dir, filename) + end -local function find_project_files_co(root, path) - local size_limit = config.file_size_limit * 10e5 + if not files_list_match(dir.files, index, n, new_files) then + files_list_replace(dir.files, index, n, new_files) + dir.is_dirty = true + return true + end +end + + +local function add_dir_scan_thread(dir) + core.add_thread(function() + while true do + local has_changes = rescan_project_subdir(dir, "") + if has_changes then + core.redraw = true -- we run without an event, from a thread + end + coroutine.yield(5) + end + end) +end + +-- Populate a project folder top directory by scanning the filesystem. +local function scan_project_folder(index) + local dir = core.project_directories[index] + if PLATFORM == "Linux" then + local fstype = system.get_fs_type(dir.name) + dir.force_rescan = (fstype == "nfs" or fstype == "fuse") + end + local t, complete, entries_count = get_directory_files(dir, dir.name, "", {}, 0, timed_max_files_pred) + if not complete then + dir.slow_filesystem = not complete and (entries_count <= config.max_project_files) + dir.files_limit = true + if not dir.force_rescan then + -- Watch non-recursively on Linux only. + -- The reason is recursively watching with dmon on linux + -- doesn't work on very large directories. + dir.watch_id = system.watch_dir(dir.name, PLATFORM ~= "Linux") + end + if core.status_view then -- May be not yet initialized. + show_max_files_warning(dir) + end + else + if not dir.force_rescan then + dir.watch_id = system.watch_dir(dir.name, true) + end + end + dir.files = t + if dir.force_rescan then + add_dir_scan_thread(dir) + else + core.dir_rescan_add_job(dir, ".") + end +end + + +function core.add_project_directory(path) + -- top directories has a file-like "item" but the item.filename + -- will be simply the name of the directory, without its path. + -- The field item.topdir will identify it as a top level directory. + path = common.normalize_volume(path) + local dir = { + name = path, + item = {filename = common.basename(path), type = "dir", topdir = true}, + files_limit = false, + is_dirty = true, + shown_subdir = {}, + } + table.insert(core.project_directories, dir) + scan_project_folder(#core.project_directories) + if path == core.project_dir then + core.project_files = dir.files + end + core.redraw = true +end + + +function core.update_project_subdir(dir, filename, expanded) + local index, n, file = project_subdir_bounds(dir, filename) + if index then + local new_files = expanded and get_directory_files(dir, dir.name, PATHSEP .. filename, {}, 0, core.project_subdir_is_shown) or {} + files_list_replace(dir.files, index, n, new_files) + dir.is_dirty = true + return true + end +end + + +-- Find files and directories recursively reading from the filesystem. +-- Filter files and yields file's directory and info table. This latter +-- is filled to be like required by project directories "files" list. +local function find_files_rec(root, path) local all = system.list_dir(root .. path) or {} for _, file in ipairs(all) do - if not common.match_pattern(file, config.ignore_files) then - local file = path .. PATHSEP .. file - local info = system.get_file_info(root .. file) - if info and info.size < size_limit then - info.filename = strip_leading_path(file) - if info.type == "file" then - coroutine.yield(root, info) - else - find_project_files_co(root, PATHSEP .. info.filename) - end + local file = path .. PATHSEP .. file + local info = system.get_file_info(root .. file) + if info then + info.filename = strip_leading_path(file) + if info.type == "file" then + coroutine.yield(root, info) + else + find_files_rec(root, PATHSEP .. info.filename) end end end end +-- Iterator function to list all project files local function project_files_iter(state) local dir = core.project_directories[state.dir_index] - state.file_index = state.file_index + 1 - while dir and state.file_index > #dir.files do - state.dir_index = state.dir_index + 1 - state.file_index = 1 - dir = core.project_directories[state.dir_index] + if state.co then + -- We have a coroutine to fetch for files, use the coroutine. + -- Used for directories that exceeds the files nuumber limit. + local ok, name, file = coroutine.resume(state.co, dir.name, "") + if ok and name then + return name, file + else + -- The coroutine terminated, increment file/dir counter to scan + -- next project directory. + state.co = false + state.file_index = 1 + state.dir_index = state.dir_index + 1 + dir = core.project_directories[state.dir_index] + end + else + -- Increase file/dir counter + state.file_index = state.file_index + 1 + while dir and state.file_index > #dir.files do + state.dir_index = state.dir_index + 1 + state.file_index = 1 + dir = core.project_directories[state.dir_index] + end end if not dir then return end + if dir.files_limit then + -- The current project directory is files limited: create a couroutine + -- to read files from the filesystem. + state.co = coroutine.create(find_files_rec) + return project_files_iter(state) + end return dir.name, dir.files[state.file_index] end function core.get_project_files() - if core.project_files_limit then - return coroutine.wrap(function() - for _, dir in ipairs(core.project_directories) do - find_project_files_co(dir.name, "") - end - end) - else - local state = { dir_index = 1, file_index = 0 } - return project_files_iter, state - end + local state = { dir_index = 1, file_index = 0 } + return project_files_iter, state end function core.project_files_number() - if not core.project_files_limit then - local n = 0 - for i = 1, #core.project_directories do - n = n + #core.project_directories[i].files + local n = 0 + for i = 1, #core.project_directories do + if core.project_directories[i].files_limit then return end + n = n + #core.project_directories[i].files + end + return n +end + + +local function project_dir_by_watch_id(watch_id) + for i = 1, #core.project_directories do + if core.project_directories[i].watch_id == watch_id then + return core.project_directories[i] end - return n + end +end + + +local function project_scan_remove_file(dir, filepath) + local fileinfo = { filename = filepath } + for _, filetype in ipairs {"dir", "file"} do + fileinfo.type = filetype + local index, match = file_search(dir.files, fileinfo) + if match then + table.remove(dir.files, index) + dir.is_dirty = true + return + end + end +end + + +local function project_scan_add_file(dir, filepath) + for fragment in string.gmatch(filepath, "([^/\\]+)") do + if common.match_pattern(fragment, config.ignore_files) then + return + end + end + local fileinfo = get_project_file_info(dir.name, PATHSEP .. filepath) + if fileinfo then + project_scan_add_entry(dir, fileinfo) end end @@ -371,19 +565,6 @@ function core.load_user_directory() end -function core.add_project_directory(path) - -- top directories has a file-like "item" but the item.filename - -- will be simply the name of the directory, without its path. - -- The field item.topdir will identify it as a top level directory. - path = common.normalize_path(path) - table.insert(core.project_directories, { - name = path, - item = {filename = common.basename(path), type = "dir", topdir = true}, - files = {} - }) -end - - function core.remove_project_directory(path) -- skip the fist directory because it is the project's directory for i = 2, #core.project_directories do @@ -422,9 +603,9 @@ function core.init() Doc = require "core.doc" if PATHSEP == '\\' then - USERDIR = common.normalize_path(USERDIR) - DATADIR = common.normalize_path(DATADIR) - EXEDIR = common.normalize_path(EXEDIR) + USERDIR = common.normalize_volume(USERDIR) + DATADIR = common.normalize_volume(DATADIR) + EXEDIR = common.normalize_volume(EXEDIR) end do @@ -509,7 +690,6 @@ function core.init() cur_node = cur_node:split("down", core.command_view, {y = true}) cur_node = cur_node:split("down", core.status_view, {y = true}) - core.project_scan_thread_id = core.add_thread(project_scan_thread) command.add_defaults() local got_user_error = not core.load_user_directory() local plugins_success, plugins_refuse_list = core.load_plugins() @@ -520,6 +700,12 @@ function core.init() end local got_project_error = not core.load_project_module() + -- We assume we have just a single project directory here. Now that StatusView + -- is there show max files warning if needed. + if core.project_directories[1].files_limit then + show_max_files_warning(core.project_directories[1]) + end + for _, filename in ipairs(files) do core.root_view:open_doc(core.open_doc(filename)) end @@ -675,16 +861,18 @@ function core.load_plugins() userdir = {dir = USERDIR, plugins = {}}, datadir = {dir = DATADIR, plugins = {}}, } - local files = {} + local files, ordered = {}, {} for _, root_dir in ipairs {DATADIR, USERDIR} do local plugin_dir = root_dir .. "/plugins" for _, filename in ipairs(system.list_dir(plugin_dir) or {}) do + if not files[filename] then table.insert(ordered, filename) end files[filename] = plugin_dir -- user plugins will always replace system plugins end end + table.sort(ordered) - for filename, plugin_dir in pairs(files) do - local basename = filename:match("(.-)%.lua$") or filename + for _, filename in ipairs(ordered) do + local plugin_dir, basename = files[filename], filename:match("(.-)%.lua$") or filename local is_lua_file, version_match = check_plugin_version(plugin_dir .. '/' .. filename) if is_lua_file then if not version_match then @@ -908,6 +1096,84 @@ function core.try(fn, ...) return false, err end +local scheduled_rescan = {} + +function core.has_pending_rescan() + for _ in pairs(scheduled_rescan) do + return true + end +end + + +function core.dir_rescan_add_job(dir, filepath) + local dirpath = filepath:match("^(.+)[/\\].+$") + local dirpath_rooted = dirpath and PATHSEP .. dirpath or "" + local abs_dirpath = dir.name .. dirpath_rooted + if dirpath then + -- check if the directory is in the project files list, if not exit + local dir_index, dir_match = file_search(dir.files, {filename = dirpath, type = "dir"}) + -- Note that is dir_match is false dir_index greaten than the last valid index. + -- We use dir_index to index dir.files below only if dir_match is true. + if not dir_match or not core.project_subdir_is_shown(dir, dir.files[dir_index].filename) then return end + end + local new_time = system.get_time() + 1 + + -- evaluate new rescan request versus existing rescan + local remove_list = {} + for _, rescan in pairs(scheduled_rescan) do + if abs_dirpath == rescan.abs_path or common.path_belongs_to(abs_dirpath, rescan.abs_path) then + -- abs_dirpath is a subpath of a scan already ongoing: skip + rescan.time_limit = new_time + return + elseif common.path_belongs_to(rescan.abs_path, abs_dirpath) then + -- abs_dirpath already cover this rescan: add to the list of rescan to be removed + table.insert(remove_list, rescan.abs_path) + end + end + for _, key_path in ipairs(remove_list) do + scheduled_rescan[key_path] = nil + end + + scheduled_rescan[abs_dirpath] = {dir = dir, path = dirpath_rooted, abs_path = abs_dirpath, time_limit = new_time} + core.add_thread(function() + while true do + local rescan = scheduled_rescan[abs_dirpath] + if not rescan then return end + if system.get_time() > rescan.time_limit then + local has_changes = rescan_project_subdir(rescan.dir, rescan.path) + if has_changes then + core.redraw = true -- we run without an event, from a thread + rescan.time_limit = new_time + else + scheduled_rescan[rescan.abs_path] = nil + return + end + end + coroutine.yield(0.2) + end + end) +end + + +-- no-op but can be overrided by plugins +function core.on_dirmonitor_modify(dir, filepath) +end + + +function core.on_dir_change(watch_id, action, filepath) + local dir = project_dir_by_watch_id(watch_id) + if not dir then return end + core.dir_rescan_add_job(dir, filepath) + if action == "delete" then + project_scan_remove_file(dir, filepath) + elseif action == "create" then + project_scan_add_file(dir, filepath) + core.on_dirmonitor_modify(dir, filepath); + elseif action == "modify" then + core.on_dirmonitor_modify(dir, filepath); + end +end + function core.on_event(type, ...) local did_keymap = false @@ -920,11 +1186,15 @@ function core.on_event(type, ...) elseif type == "mousemoved" then core.root_view:on_mouse_moved(...) elseif type == "mousepressed" then - core.root_view:on_mouse_pressed(...) + if not core.root_view:on_mouse_pressed(...) then + did_keymap = keymap.on_mouse_pressed(...) + end elseif type == "mousereleased" then core.root_view:on_mouse_released(...) elseif type == "mousewheel" then - core.root_view:on_mouse_wheel(...) + if not core.root_view:on_mouse_wheel(...) then + did_keymap = keymap.on_mouse_wheel(...) + end elseif type == "resized" then core.window_mode = system.get_window_mode() elseif type == "minimized" or type == "maximized" or type == "restored" then @@ -944,6 +1214,8 @@ function core.on_event(type, ...) end elseif type == "focuslost" then core.root_view:on_focus_lost(...) + elseif type == "dirchange" then + core.on_dir_change(...) elseif type == "quit" then core.quit() end @@ -1050,7 +1322,7 @@ function core.run() while true do core.frame_start = system.get_time() local did_redraw = core.step() - local need_more_work = run_threads() + local need_more_work = run_threads() or core.has_pending_rescan() if core.restart_request or core.quit_request then break end if not did_redraw and not need_more_work then idle_iterations = idle_iterations + 1 diff --git a/data/core/keymap-macos.lua b/data/core/keymap-macos.lua index 53a20468..b0bd41a5 100644 --- a/data/core/keymap-macos.lua +++ b/data/core/keymap-macos.lua @@ -32,6 +32,8 @@ local function keymap_macos(keymap) ["cmd+7"] = "root:switch-to-tab-7", ["cmd+8"] = "root:switch-to-tab-8", ["cmd+9"] = "root:switch-to-tab-9", + ["wheel"] = "root:scroll", + ["cmd+f"] = "find-replace:find", ["cmd+r"] = "find-replace:replace", ["f3"] = "find-replace:repeat-find", @@ -93,6 +95,11 @@ local function keymap_macos(keymap) ["pageup"] = "doc:move-to-previous-page", ["pagedown"] = "doc:move-to-next-page", + ["shift+1lclick"] = "doc:select-to-cursor", + ["ctrl+1lclick"] = "doc:split-cursor", + ["1lclick"] = "doc:set-cursor", + ["2lclick"] = "doc:set-cursor-word", + ["3lclick"] = "doc:set-cursor-line", ["shift+left"] = "doc:select-to-previous-char", ["shift+right"] = "doc:select-to-next-char", ["shift+up"] = "doc:select-to-previous-line", diff --git a/data/core/keymap.lua b/data/core/keymap.lua index 7f7c0fe2..b076629b 100644 --- a/data/core/keymap.lua +++ b/data/core/keymap.lua @@ -1,4 +1,5 @@ local command = require "core.command" +local config = require "core.config" local keymap = {} keymap.modkeys = {} @@ -30,7 +31,8 @@ function keymap.add_direct(map) end keymap.map[stroke] = commands for _, cmd in ipairs(commands) do - keymap.reverse_map[cmd] = stroke + keymap.reverse_map[cmd] = keymap.reverse_map[cmd] or {} + table.insert(keymap.reverse_map[cmd], stroke) end end end @@ -52,18 +54,43 @@ function keymap.add(map, overwrite) end end for _, cmd in ipairs(commands) do - keymap.reverse_map[cmd] = stroke + keymap.reverse_map[cmd] = keymap.reverse_map[cmd] or {} + table.insert(keymap.reverse_map[cmd], stroke) end end end -function keymap.get_binding(cmd) - return keymap.reverse_map[cmd] +local function remove_only(tbl, k, v) + for key, values in pairs(tbl) do + if key == k then + if v then + for i, value in ipairs(values) do + if value == v then + table.remove(values, i) + end + end + else + tbl[key] = nil + end + break + end + end end -function keymap.on_key_pressed(k) +function keymap.unbind(key, cmd) + remove_only(keymap.map, key, cmd) + remove_only(keymap.reverse_map, cmd, key) +end + + +function keymap.get_binding(cmd) + return table.unpack(keymap.reverse_map[cmd] or {}) +end + + +function keymap.on_key_pressed(k, ...) local mk = modkey_map[k] if mk then keymap.modkeys[mk] = true @@ -73,18 +100,30 @@ function keymap.on_key_pressed(k) end else local stroke = key_to_stroke(k) - local commands = keymap.map[stroke] + local commands, performed = keymap.map[stroke] if commands then for _, cmd in ipairs(commands) do - local performed = command.perform(cmd) + performed = command.perform(cmd, ...) if performed then break end end - return true + return performed end end return false end +function keymap.on_mouse_wheel(delta, ...) + return not (keymap.on_key_pressed("wheel" .. (delta > 0 and "up" or "down"), delta, ...) + or keymap.on_key_pressed("wheel", delta, ...)) +end + +function keymap.on_mouse_pressed(button, x, y, clicks) + local click_number = (((clicks - 1) % config.max_clicks) + 1) + return not (keymap.on_key_pressed(click_number .. button:sub(1,1) .. "click", x, y, clicks) or + keymap.on_key_pressed(button:sub(1,1) .. "click", x, y, clicks) or + keymap.on_key_pressed(click_number .. "click", x, y, clicks) or + keymap.on_key_pressed("click", x, y, clicks)) +end function keymap.on_key_released(k) local mk = modkey_map[k] @@ -133,6 +172,7 @@ keymap.add_direct { ["alt+7"] = "root:switch-to-tab-7", ["alt+8"] = "root:switch-to-tab-8", ["alt+9"] = "root:switch-to-tab-9", + ["wheel"] = "root:scroll", ["ctrl+f"] = "find-replace:find", ["ctrl+r"] = "find-replace:replace", @@ -170,6 +210,7 @@ keymap.add_direct { ["ctrl+a"] = "doc:select-all", ["ctrl+d"] = { "find-replace:select-add-next", "doc:select-word" }, ["ctrl+f3"] = "find-replace:select-next", + ["ctrl+shift+f3"] = "find-replace:select-previous", ["ctrl+l"] = "doc:select-lines", ["ctrl+shift+l"] = { "find-replace:select-add-all", "doc:select-word" }, ["ctrl+/"] = "doc:toggle-line-comments", @@ -193,6 +234,11 @@ keymap.add_direct { ["pageup"] = "doc:move-to-previous-page", ["pagedown"] = "doc:move-to-next-page", + ["shift+1lclick"] = "doc:select-to-cursor", + ["ctrl+1lclick"] = "doc:split-cursor", + ["1lclick"] = "doc:set-cursor", + ["2lclick"] = "doc:set-cursor-word", + ["3lclick"] = "doc:set-cursor-line", ["shift+left"] = "doc:select-to-previous-char", ["shift+right"] = "doc:select-to-next-char", ["shift+up"] = "doc:select-to-previous-line", diff --git a/data/core/regex.lua b/data/core/regex.lua index 69203cbd..637d23fd 100644 --- a/data/core/regex.lua +++ b/data/core/regex.lua @@ -1,4 +1,3 @@ - -- So that in addition to regex.gsub(pattern, string), we can also do -- pattern:gsub(string). regex.__index = function(table, key) return regex[key]; end @@ -6,7 +5,8 @@ regex.__index = function(table, key) return regex[key]; end regex.match = function(pattern_string, string, offset, options) local pattern = type(pattern_string) == "table" and pattern_string or regex.compile(pattern_string) - return regex.cmatch(pattern, string, offset or 1, options or 0) + local s, e = regex.cmatch(pattern, string, offset or 1, options or 0) + return s, e and e - 1 end -- Will iterate back through any UTF-8 bytes so that we don't replace bits diff --git a/data/core/rootview.lua b/data/core/rootview.lua index 0d219474..97caca1c 100644 --- a/data/core/rootview.lua +++ b/data/core/rootview.lua @@ -149,10 +149,17 @@ function Node:remove_view(root, view) else locked_size = locked_size_y end - if self.is_primary_node or locked_size then + local next_primary + if self.is_primary_node then + next_primary = core.root_view:select_next_primary_node() + end + if locked_size or (self.is_primary_node and not next_primary) then self.views = {} self:add_view(EmptyView()) else + if other == next_primary then + next_primary = parent + end parent:consume(other) local p = parent while p.type ~= "leaf" do @@ -160,7 +167,7 @@ function Node:remove_view(root, view) end p:set_active_view(p.active_view) if self.is_primary_node then - p.is_primary_node = true + next_primary.is_primary_node = true end end end @@ -411,15 +418,8 @@ 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, y1, y2) - 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 + local n = x1 and x1 + ds or (x2 and self.size[x] - x2 or math.floor(self.size[x] * self.divider)) self.a.position[x] = self.position[x] self.a.position[y] = self.position[y] self.a.size[x] = n - ds @@ -602,7 +602,7 @@ function Node:draw() 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) + core.push_clip_rect(pos.x, pos.y, size.x, size.y) self.active_view:draw() core.pop_clip_rect() else @@ -682,6 +682,10 @@ end function Node:resize(axis, value) + -- the application works fine with non-integer values but to have pixel-perfect + -- placements of view elements, like the scrollbar, we round the value to be + -- an integer. + value = math.floor(value) if self.type == 'leaf' then -- If it is not locked we don't accept the -- resize operation here because for proportional panes the resize is @@ -826,6 +830,24 @@ function RootView:get_primary_node() end +local function select_next_primary_node(node) + if node.is_primary_node then return end + if node.type ~= "leaf" then + return select_next_primary_node(node.a) or select_next_primary_node(node.b) + else + local lx, ly = node:get_locked_size() + if not lx and not ly then + return node + end + end +end + + +function RootView:select_next_primary_node() + return select_next_primary_node(self.root_node) +end + + function RootView:open_doc(doc) local node = self:get_active_node_default() for i, view in ipairs(node.views) do @@ -855,30 +877,30 @@ 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) + if div and (node and not node.active_view:scrollbar_overlaps_point(x, y)) then + self.dragged_divider = div + return true + end if node.hovered_scroll_button > 0 then node:scroll_tabs(node.hovered_scroll_button) - return + return true end local idx = node:get_tab_overlapping_point(x, y) if idx then if button == "middle" or node.hovered_close == idx then node:close_view(self.root_node, node.views[idx]) + return true else if button == "left" then self.dragged_node = { node = node, idx = idx, dragging = false, drag_start_x = x, drag_start_y = y} end node:set_active_view(node.views[idx]) + return true end elseif not self.dragged_node then -- avoid sending on_mouse_pressed events when dragging tabs core.set_active_view(node.active_view) - if not self.on_view_mouse_pressed(button, x, y, clicks) then - node.active_view:on_mouse_pressed(button, x, y, clicks) - end + return self.on_view_mouse_pressed(button, x, y, clicks) or node.active_view:on_mouse_pressed(button, x, y, clicks) end end @@ -1000,17 +1022,18 @@ function RootView:on_mouse_moved(x, y, dx, dy) self.root_node:on_mouse_moved(x, y, dx, dy) - local node = self.root_node:get_child_overlapping_point(x, y) + self.overlapping_node = self.root_node:get_child_overlapping_point(x, y) + local div = self.root_node:get_divider_overlapping_point(x, y) - local tab_index = node and node:get_tab_overlapping_point(x, y) - if node and node:get_scroll_button_index(x, y) then + local tab_index = self.overlapping_node and self.overlapping_node:get_tab_overlapping_point(x, y) + if self.overlapping_node and self.overlapping_node:get_scroll_button_index(x, y) then core.request_cursor("arrow") - elseif div then + elseif div and (self.overlapping_node and not self.overlapping_node.active_view:scrollbar_overlaps_point(x, y)) then core.request_cursor(div.type == "hsplit" and "sizeh" or "sizev") elseif tab_index then core.request_cursor("arrow") - elseif node then - core.request_cursor(node.active_view.cursor) + elseif self.overlapping_node then + core.request_cursor(self.overlapping_node.active_view.cursor) end end @@ -1018,7 +1041,7 @@ 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(...) + return node.active_view:on_mouse_wheel(...) end @@ -1100,10 +1123,10 @@ function RootView:update_drag_overlay() if split_type == "tab" and (over ~= self.dragged_node.node or #over.views > 1) then local tab_index, tab_x, tab_y, tab_w, tab_h = over:get_drag_overlay_tab_position(self.mouse.x, self.mouse.y) self:set_drag_overlay(self.drag_overlay_tab, - tab_x + (tab_index and 0 or tab_w), tab_y, - style.caret_width, tab_h, - -- avoid showing tab overlay moving between nodes - over ~= self.drag_overlay_tab.last_over) + tab_x + (tab_index and 0 or tab_w), tab_y, + style.caret_width, tab_h, + -- avoid showing tab overlay moving between nodes + over ~= self.drag_overlay_tab.last_over) self:set_show_overlay(self.drag_overlay, false) self.drag_overlay_tab.last_over = over else diff --git a/data/core/start.lua b/data/core/start.lua index 330c42f5..f3bc89c6 100644 --- a/data/core/start.lua +++ b/data/core/start.lua @@ -2,7 +2,7 @@ VERSION = "@PROJECT_VERSION@" MOD_VERSION = "2" -SCALE = tonumber(os.getenv("LITE_SCALE")) or SCALE +SCALE = tonumber(os.getenv("LITE_SCALE") or os.getenv("GDK_SCALE") or os.getenv("QT_SCALE_FACTOR")) or SCALE PATHSEP = package.config:sub(1, 1) EXEDIR = EXEFILE:match("^(.+)[/\\][^/\\]+$") @@ -28,3 +28,6 @@ package.searchers = { package.searchers[1], package.searchers[2], function(modna if not path then return nil end return system.load_native_plugin, path end } + +table.pack = table.pack or pack or function(...) return {...} end +table.unpack = table.unpack or unpack diff --git a/data/core/statusview.lua b/data/core/statusview.lua index 3342bdb9..59773cf0 100644 --- a/data/core/statusview.lua +++ b/data/core/statusview.lua @@ -108,9 +108,9 @@ function StatusView:get_items() local dv = core.active_view local line, col = dv.doc:get_selection() local dirty = dv.doc:is_dirty() - local indent = dv.doc.indent_info - local indent_label = (indent and indent.type == "hard") and "tabs: " or "spaces: " - local indent_size = indent and tostring(indent.size) .. (indent.confirmed and "" or "*") or "unknown" + local indent_type, indent_size, indent_confirmed = dv.doc:get_indent_info() + local indent_label = (indent_type == "hard") and "tabs: " or "spaces: " + local indent_size_str = tostring(indent_size) .. (indent_confirmed and "" or "*") or "unknown" return { dirty and style.accent or style.text, style.icon_font, "f", diff --git a/data/core/syntax.lua b/data/core/syntax.lua index a763ac78..adecd0cd 100644 --- a/data/core/syntax.lua +++ b/data/core/syntax.lua @@ -3,7 +3,7 @@ local common = require "core.common" local syntax = {} syntax.items = {} -local plain_text_syntax = { patterns = {}, symbols = {} } +local plain_text_syntax = { name = "Plain Text", patterns = {}, symbols = {} } function syntax.add(t) @@ -22,7 +22,7 @@ end function syntax.get(filename, header) return find(filename, "files") - or find(header, "headers") + or (header and find(header, "headers")) or plain_text_syntax end diff --git a/data/core/tokenizer.lua b/data/core/tokenizer.lua index a20dba5e..f77fed44 100644 --- a/data/core/tokenizer.lua +++ b/data/core/tokenizer.lua @@ -1,4 +1,5 @@ local syntax = require "core.syntax" +local common = require "core.common" local tokenizer = {} @@ -142,8 +143,13 @@ function tokenizer.tokenize(incoming_syntax, text, state) code = p._regex end repeat - res = p.pattern and { text:find(at_start and "^" .. code or code, res[2]+1) } - or { regex.match(code, text, res[2]+1, at_start and regex.ANCHORED or 0) } + local next = res[2] + 1 + -- go to the start of the next utf-8 character + while text:byte(next) and common.is_utf8_cont(text, next) do + next = next + 1 + end + res = p.pattern and { text:find(at_start and "^" .. code or code, next) } + or { regex.match(code, text, next, at_start and regex.ANCHORED or 0) } if res[1] and close and target[3] then local count = 0 for i = res[1] - 1, 1, -1 do @@ -155,7 +161,7 @@ function tokenizer.tokenize(incoming_syntax, text, state) if count % 2 == 0 then break end end until not res[1] or not close or not target[3] - return unpack(res) + return table.unpack(res) end while i <= #text do diff --git a/data/core/view.lua b/data/core/view.lua index d1374ee4..4b787d46 100644 --- a/data/core/view.lua +++ b/data/core/view.lua @@ -102,13 +102,9 @@ 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 +end function View:get_content_bounds() local x = self.scroll.x @@ -140,7 +136,7 @@ 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) + renderer.draw_rect(x, y, w, h, color) end diff --git a/data/plugins/autoreload.lua b/data/plugins/autoreload.lua index e772666f..9978092e 100644 --- a/data/plugins/autoreload.lua +++ b/data/plugins/autoreload.lua @@ -3,7 +3,6 @@ local core = require "core" local config = require "core.config" local Doc = require "core.doc" - local times = setmetatable({}, { __mode = "k" }) local function update_time(doc) @@ -11,7 +10,6 @@ local function update_time(doc) times[doc] = info.modified end - local function reload_doc(doc) local fp = io.open(doc.filename, "r") local text = fp:read("*a") @@ -27,23 +25,19 @@ local function reload_doc(doc) core.log_quiet("Auto-reloaded doc \"%s\"", doc.filename) end +local on_modify = core.on_dirmonitor_modify -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() +core.on_dirmonitor_modify = function(dir, filepath) + local abs_filename = dir.name .. PATHSEP .. filepath + for _, doc in ipairs(core.docs) do + local info = system.get_file_info(doc.filename or "") + if doc.abs_filename == abs_filename and info and times[doc] ~= info.modified then + reload_doc(doc) + break end - - -- wait for next scan - coroutine.yield(config.project_scan_rate) end -end) - + on_modify(dir, filepath) +end -- patch `Doc.save|load` to store modified time local load = Doc.load diff --git a/data/plugins/contextmenu.lua b/data/plugins/contextmenu.lua index dc95567f..4b34dfd5 100644 --- a/data/plugins/contextmenu.lua +++ b/data/plugins/contextmenu.lua @@ -62,15 +62,15 @@ menu:register("core.logview", { if require("plugins.scale") then menu:register("core.docview", { - { text = "Font +", command = "scale:increase" }, - { text = "Font -", command = "scale:decrease" }, - { text = "Font Reset", command = "scale:reset" }, + { text = "Cut", command = "doc:cut" }, + { text = "Copy", command = "doc:copy" }, + { text = "Paste", command = "doc:paste" }, + { text = "Font +", command = "scale:increase" }, + { text = "Font -", command = "scale:decrease" }, + { text = "Font Reset", command = "scale:reset" }, ContextMenu.DIVIDER, - { text = "Find", command = "find-replace:find" }, - { text = "Replace", command = "find-replace:replace" }, - ContextMenu.DIVIDER, - { text = "Find Pattern", command = "find-replace:find-pattern" }, - { text = "Replace Pattern", command = "find-replace:replace-pattern" }, + { text = "Find", command = "find-replace:find" }, + { text = "Replace", command = "find-replace:replace" } }) end diff --git a/data/plugins/detectindent.lua b/data/plugins/detectindent.lua index 20541c82..9ac29882 100644 --- a/data/plugins/detectindent.lua +++ b/data/plugins/detectindent.lua @@ -121,40 +121,17 @@ end local clean = Doc.clean function Doc:clean(...) clean(self, ...) - if not cache[self].confirmed then + local _, _, confirmed = self:get_indent_info() + if not confirmed then update_cache(self) end end -local function with_indent_override(doc, fn, ...) - local c = cache[doc] - if not c then - return fn(...) - end - local type, size = config.tab_type, config.indent_size - config.tab_type, config.indent_size = c.type, c.size or config.indent_size - local r1, r2, r3 = fn(...) - config.tab_type, config.indent_size = type, size - return r1, r2, r3 -end - - -local perform = command.perform -function command.perform(...) - return with_indent_override(core.active_view.doc, perform, ...) -end - - -local draw = DocView.draw -function DocView:draw(...) - return with_indent_override(self.doc, draw, self, ...) -end - - local function set_indent_type(doc, type) + local _, indent_size = doc:get_indent_info() cache[doc] = {type = type, - size = cache[doc].value or config.indent_size, + size = indent_size, confirmed = true} doc.indent_info = cache[doc] end @@ -180,7 +157,8 @@ end local function set_indent_size(doc, size) - cache[doc] = {type = cache[doc].type or config.tab_type, + local indent_type = doc:get_indent_info() + cache[doc] = {type = indent_type, size = size, confirmed = true} doc.indent_info = cache[doc] diff --git a/data/plugins/drawwhitespace.lua b/data/plugins/drawwhitespace.lua index da9d1b12..0004c7ea 100644 --- a/data/plugins/drawwhitespace.lua +++ b/data/plugins/drawwhitespace.lua @@ -7,26 +7,30 @@ local common = require "core.common" local draw_line_text = DocView.draw_line_text function DocView:draw_line_text(idx, x, y) - local font = (self:get_font() or style.syntax_fonts["comment"]) - local color = style.syntax.comment - local ty, tx = y + self:get_line_text_y_offset() + local font = (self:get_font() or style.syntax_fonts["whitespace"] or style.syntax_fonts["comment"]) + local color = style.syntax.whitespace or style.syntax.comment + local ty = y + self:get_line_text_y_offset() + local tx local text, offset, s, e = self.doc.lines[idx], 1 + local x1, _, x2, _ = self:get_content_bounds() + local _offset = self:get_x_offset_col(idx, x1) + offset = _offset while true do s, e = text:find(" +", offset) if not s then break end tx = self:get_col_x_offset(idx, s) + x renderer.draw_text(font, string.rep("·", e - s + 1), tx, ty, color) + if tx > x + x2 then break end offset = e + 1 end - offset = 1 + offset = _offset while true do s, e = text:find("\t", offset) if not s then break end tx = self:get_col_x_offset(idx, s) + x renderer.draw_text(font, "»", tx, ty, color) + if tx > x + x2 then break end offset = e + 1 end draw_line_text(self, idx, x, y) end - - diff --git a/data/plugins/language_c.lua b/data/plugins/language_c.lua index 44c3b895..b0a4dec5 100644 --- a/data/plugins/language_c.lua +++ b/data/plugins/language_c.lua @@ -2,6 +2,7 @@ local syntax = require "core.syntax" syntax.add { + name = "C", files = { "%.c$", "%.h$", "%.inl$" }, comment = "//", patterns = { diff --git a/data/plugins/language_cpp.lua b/data/plugins/language_cpp.lua index 499a09db..8d6aef4b 100644 --- a/data/plugins/language_cpp.lua +++ b/data/plugins/language_cpp.lua @@ -4,6 +4,7 @@ pcall(require, "plugins.language_c") local syntax = require "core.syntax" syntax.add { + name = "C++", files = { "%.h$", "%.inl$", "%.cpp$", "%.cc$", "%.C$", "%.cxx$", "%.c++$", "%.hh$", "%.H$", "%.hxx$", "%.hpp$", "%.h++$" diff --git a/data/plugins/language_css.lua b/data/plugins/language_css.lua index 222e2f94..395e375c 100644 --- a/data/plugins/language_css.lua +++ b/data/plugins/language_css.lua @@ -2,6 +2,7 @@ local syntax = require "core.syntax" syntax.add { + name = "CSS", files = { "%.css$" }, patterns = { { pattern = "\\.", type = "normal" }, diff --git a/data/plugins/language_html.lua b/data/plugins/language_html.lua index cebb3f1a..1f4515bc 100644 --- a/data/plugins/language_html.lua +++ b/data/plugins/language_html.lua @@ -2,6 +2,7 @@ local syntax = require "core.syntax" syntax.add { + name = "HTML", files = { "%.html?$" }, patterns = { { diff --git a/data/plugins/language_js.lua b/data/plugins/language_js.lua index 7556b00b..d9515d52 100644 --- a/data/plugins/language_js.lua +++ b/data/plugins/language_js.lua @@ -2,6 +2,7 @@ local syntax = require "core.syntax" syntax.add { + name = "JavaScript", files = { "%.js$", "%.json$", "%.cson$" }, comment = "//", patterns = { @@ -11,8 +12,8 @@ syntax.add { { pattern = { '"', '"', '\\' }, type = "string" }, { pattern = { "'", "'", '\\' }, type = "string" }, { pattern = { "`", "`", '\\' }, type = "string" }, - { pattern = "0x[%da-fA-F]+", type = "number" }, - { pattern = "-?%d+[%d%.eE]*", type = "number" }, + { pattern = "0x[%da-fA-F_]+n?", type = "number" }, + { pattern = "-?%d+[%d%.eE_n]*", type = "number" }, { pattern = "-?%.?%d+", type = "number" }, { pattern = "[%+%-=/%*%^%%<>!~|&]", type = "operator" }, { pattern = "[%a_][%w_]*%f[(]", type = "function" }, diff --git a/data/plugins/language_lua.lua b/data/plugins/language_lua.lua index 165633b6..5c770d43 100644 --- a/data/plugins/language_lua.lua +++ b/data/plugins/language_lua.lua @@ -2,6 +2,7 @@ local syntax = require "core.syntax" syntax.add { + name = "Lua", files = "%.lua$", headers = "^#!.*[ /]lua", comment = "--", diff --git a/data/plugins/language_md.lua b/data/plugins/language_md.lua index 3c1c329a..e7c870ec 100644 --- a/data/plugins/language_md.lua +++ b/data/plugins/language_md.lua @@ -4,11 +4,11 @@ local syntax = require "core.syntax" syntax.add { + name = "Markdown", files = { "%.md$", "%.markdown$" }, patterns = { { pattern = "\\.", type = "normal" }, { pattern = { "" }, type = "comment" }, - { pattern = { "```c", "```" }, type = "string", syntax = ".c" }, { pattern = { "```c++", "```" }, type = "string", syntax = ".cpp" }, { pattern = { "```python", "```" }, type = "string", syntax = ".py" }, { pattern = { "```ruby", "```" }, type = "string", syntax = ".rb" }, @@ -25,6 +25,21 @@ syntax.add { { pattern = { "```cmake", "```" }, type = "string", syntax = ".cmake" }, { pattern = { "```d", "```" }, type = "string", syntax = ".d" }, { pattern = { "```glsl", "```" }, type = "string", syntax = ".glsl" }, + { pattern = { "```c", "```" }, type = "string", syntax = ".c" }, + { pattern = { "```julia", "```" }, type = "string", syntax = ".jl" }, + { pattern = { "```rust", "```" }, type = "string", syntax = ".rs" }, + { pattern = { "```dart", "```" }, type = "string", syntax = ".dart" }, + { pattern = { "```v", "```" }, type = "string", syntax = ".v" }, + { pattern = { "```toml", "```" }, type = "string", syntax = ".toml" }, + { pattern = { "```yaml", "```" }, type = "string", syntax = ".yaml" }, + { pattern = { "```php", "```" }, type = "string", syntax = ".php" }, + { pattern = { "```nim", "```" }, type = "string", syntax = ".nim" }, + { pattern = { "```typescript", "```" }, type = "string", syntax = ".ts" }, + { pattern = { "```rescript", "```" }, type = "string", syntax = ".res" }, + { pattern = { "```moon", "```" }, type = "string", syntax = ".moon" }, + { pattern = { "```go", "```" }, type = "string", syntax = ".go" }, + { pattern = { "```lobster", "```" }, type = "string", syntax = ".lobster" }, + { pattern = { "```liquid", "```" }, type = "string", syntax = ".liquid" }, { pattern = { "```", "```" }, type = "string" }, { pattern = { "``", "``", "\\" }, type = "string" }, { pattern = { "`", "`", "\\" }, type = "string" }, diff --git a/data/plugins/language_python.lua b/data/plugins/language_python.lua index 252a0d14..8bc6fbd4 100644 --- a/data/plugins/language_python.lua +++ b/data/plugins/language_python.lua @@ -2,20 +2,21 @@ local syntax = require "core.syntax" syntax.add { + name = "Python", files = { "%.py$", "%.pyw$", "%.rpy$" }, 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" }, + { pattern = { "#", "\n" }, type = "comment" }, + { pattern = { '[ruU]?"""', '"""'; '\\' }, type = "string" }, + { pattern = { '[ruU]?"', '"', '\\' }, type = "string" }, + { pattern = { "[ruU]?'", "'", '\\' }, 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", diff --git a/data/plugins/language_xml.lua b/data/plugins/language_xml.lua index 95e310bb..c858d3cf 100644 --- a/data/plugins/language_xml.lua +++ b/data/plugins/language_xml.lua @@ -2,6 +2,7 @@ local syntax = require "core.syntax" syntax.add { + name = "XML", files = { "%.xml$" }, headers = "<%?xml", patterns = { diff --git a/data/plugins/lineguide.lua b/data/plugins/lineguide.lua index 5a17c8ca..96745659 100644 --- a/data/plugins/lineguide.lua +++ b/data/plugins/lineguide.lua @@ -2,19 +2,20 @@ local config = require "core.config" local style = require "core.style" local DocView = require "core.docview" +local CommandView = require "core.commandview" local draw_overlay = DocView.draw_overlay function DocView:draw_overlay(...) - local ns = ("n"):rep(config.line_limit) - local offset = self:get_font():get_width(ns) - local x = self:get_line_screen_position(1) + offset - local y = self.position.y - local w = math.ceil(SCALE * 1) - local h = self.size.y - - local color = style.guide or style.selection - renderer.draw_rect(x, y, w, h, color) - + if not self:is(CommandView) then + local offset = self:get_font():get_width("n") * config.line_limit + local x = self:get_line_screen_position(1) + offset + local y = self.position.y + local w = math.ceil(SCALE * 1) + local h = self.size.y + + local color = style.guide or style.selection + renderer.draw_rect(x, y, w, h, color) + end draw_overlay(self, ...) end diff --git a/data/plugins/projectsearch.lua b/data/plugins/projectsearch.lua index dda3a2d0..d0d75d7f 100644 --- a/data/plugins/projectsearch.lua +++ b/data/plugins/projectsearch.lua @@ -92,7 +92,7 @@ end function ResultsView:on_mouse_pressed(...) local caught = ResultsView.super.on_mouse_pressed(self, ...) if not caught then - self:open_selected_result() + return self:open_selected_result() end end @@ -108,6 +108,7 @@ function ResultsView:open_selected_result() dv.doc:set_selection(res.line, res.col) dv:scroll_to_line(res.line, false, true) end) + return true end diff --git a/data/plugins/scale.lua b/data/plugins/scale.lua index b8384609..616ee40b 100644 --- a/data/plugins/scale.lua +++ b/data/plugins/scale.lua @@ -54,6 +54,10 @@ local function set_scale(scale) renderer.font.set_size(font, s * font:get_size()) end + for _, font in pairs(style.syntax_fonts) do + renderer.font.set_size(font, s * font:get_size()) + end + -- restore scroll positions for view, n in pairs(scrolls) do view.scroll.y = n * (view:get_scrollable_size() - view.size.y) @@ -67,17 +71,6 @@ local function get_scale() return current_scale end -local on_mouse_wheel = RootView.on_mouse_wheel - -function RootView:on_mouse_wheel(d, ...) - if keymap.modkeys["ctrl"] and config.plugins.scale.use_mousewheel then - if d < 0 then command.perform "scale:decrease" end - if d > 0 then command.perform "scale:increase" end - else - return on_mouse_wheel(self, d, ...) - end -end - local function res_scale() set_scale(default_scale) end @@ -101,6 +94,8 @@ keymap.add { ["ctrl+0"] = "scale:reset", ["ctrl+-"] = "scale:decrease", ["ctrl+="] = "scale:increase", + ["ctrl+wheelup"] = "scale:increase", + ["ctrl+wheeldown"] = "scale:decrease" } return { diff --git a/data/plugins/treeview.lua b/data/plugins/treeview.lua index fa3ab53a..4f5db701 100644 --- a/data/plugins/treeview.lua +++ b/data/plugins/treeview.lua @@ -41,8 +41,15 @@ function TreeView:new() self.init_size = true self.target_size = default_treeview_size self.cache = {} - self.last = {} self.tooltip = { x = 0, y = 0, begin = 0, alpha = 0 } + + local on_dirmonitor_modify = core.on_dirmonitor_modify + function core.on_dirmonitor_modify(dir, filepath) + if self.cache[dir.name] then + self.cache[dir.name][filepath] = nil + end + on_dirmonitor_modify(dir, filepath) + end end @@ -54,7 +61,7 @@ function TreeView:set_target_size(axis, value) end -function TreeView:get_cached(item, dirname) +function TreeView:get_cached(dir, item, dirname) local dir_cache = self.cache[dirname] if not dir_cache then dir_cache = {} @@ -80,6 +87,7 @@ function TreeView:get_cached(item, dirname) end t.name = basename t.type = item.type + t.dir = dir -- points to top level "dir" item dir_cache[cache_name] = t end return t @@ -104,18 +112,13 @@ end function TreeView:check_cache() - -- invalidate cache's skip values if project_files has changed for i = 1, #core.project_directories do local dir = core.project_directories[i] - local last_files = self.last[dir.name] - if not last_files then - self.last[dir.name] = dir.files - else - if dir.files ~= last_files then - self:invalidate_cache(dir.name) - self.last[dir.name] = dir.files - end + -- invalidate cache's skip values if directory is declared dirty + if dir.is_dirty and self.cache[dir.name] then + self:invalidate_cache(dir.name) end + dir.is_dirty = false end end @@ -131,14 +134,14 @@ function TreeView:each_item() for k = 1, #core.project_directories do local dir = core.project_directories[k] - local dir_cached = self:get_cached(dir.item, dir.name) + local dir_cached = self:get_cached(dir, dir.item, dir.name) coroutine.yield(dir_cached, ox, y, w, h) count_lines = count_lines + 1 y = y + h local i = 1 while i <= #dir.files and dir_cached.expanded do local item = dir.files[i] - local cached = self:get_cached(item, dir.name) + local cached = self:get_cached(dir, item, dir.name) coroutine.yield(cached, ox, y, w, h) count_lines = count_lines + 1 @@ -206,7 +209,6 @@ local function create_directory_in(item) core.error("cannot create directory %q: %s", dirname, err) end item.expanded = true - core.reschedule_project_scan() end) end @@ -223,26 +225,17 @@ function TreeView:on_mouse_pressed(button, x, y, clicks) if keymap.modkeys["ctrl"] and button == "left" then create_directory_in(hovered_item) else - if core.project_files_limit and not hovered_item.expanded then - local filename, abs_filename = hovered_item.filename, hovered_item.abs_filename - local index = 0 - -- The loop below is used to find the first match starting from the end - -- in case there are multiple matches. - while index and index + #filename < #abs_filename do - index = string.find(abs_filename, filename, index + 1, true) - end - -- we assume here index is not nil because the abs_filename must contain the - -- relative filename - local dirname = string.sub(abs_filename, 1, index - 2) - if core.is_project_folder(dirname) then - core.scan_project_folder(dirname, filename) - self:invalidate_cache(dirname) - end - end hovered_item.expanded = not hovered_item.expanded + if hovered_item.dir.files_limit then + core.update_project_subdir(hovered_item.dir, hovered_item.filename, hovered_item.expanded) + core.project_subdir_set_show(hovered_item.dir, hovered_item.filename, hovered_item.expanded) + end end else core.try(function() + if core.last_active_view and core.active_view == self then + core.set_active_view(core.last_active_view) + end local doc_filename = core.normalize_to_project_dir(hovered_item.abs_filename) core.root_view:open_doc(core.open_doc(doc_filename)) end) @@ -295,6 +288,12 @@ function TreeView:draw_tooltip() end +function TreeView:color_for_item(abs_filename) + -- other plugins can override this to customize the color of each icon + return nil +end + + function TreeView:draw() self:draw_background(style.background2) @@ -318,6 +317,9 @@ function TreeView:draw() color = style.accent end + -- allow for color overrides + local icon_color = self:color_for_item(item.abs_filename) or color + -- icons x = x + item.depth * style.padding.x + style.padding.x if item.type == "dir" then @@ -325,11 +327,11 @@ function TreeView:draw() 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) + common.draw_text(style.icon_font, icon_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) + common.draw_text(style.icon_font, icon_color, "f", nil, x, y, 0, h) x = x + icon_width end @@ -404,7 +406,7 @@ function RootView:draw(...) end local function is_project_folder(path) - return common.basename(core.project_dir) == path + return core.project_dir == path end menu:register(function() return view.hovered_item end, { @@ -415,7 +417,7 @@ menu:register(function() return view.hovered_item end, { menu:register( function() return view.hovered_item - and not is_project_folder(view.hovered_item.filename) + and not is_project_folder(view.hovered_item.abs_filename) end, { { text = "Rename", command = "treeview:rename" }, @@ -461,14 +463,12 @@ command.add(function() return view.hovered_item ~= nil end, { else core.error("Error while renaming \"%s\" to \"%s\": %s", old_abs_filename, abs_filename, err) end - core.reschedule_project_scan() end, common.path_suggest) end, ["treeview:new-file"] = function() - local dir_name = view.hovered_item.filename - if not is_project_folder(dir_name) then - core.command_view:set_text(dir_name .. "/") + if not is_project_folder(view.hovered_item.abs_filename) then + core.command_view:set_text(view.hovered_item.filename .. "/") end core.command_view:enter("Filename", function(filename) local doc_filename = core.project_dir .. PATHSEP .. filename @@ -476,20 +476,17 @@ command.add(function() return view.hovered_item ~= nil end, { file:write("") file:close() core.root_view:open_doc(core.open_doc(doc_filename)) - core.reschedule_project_scan() core.log("Created %s", doc_filename) end, common.path_suggest) end, ["treeview:new-folder"] = function() - local dir_name = view.hovered_item.filename - if not is_project_folder(dir_name) then - core.command_view:set_text(dir_name .. "/") + if not is_project_folder(view.hovered_item.abs_filename) then + core.command_view:set_text(view.hovered_item.filename .. "/") end core.command_view:enter("Folder Name", function(filename) local dir_path = core.project_dir .. PATHSEP .. filename common.mkdirp(dir_path) - core.reschedule_project_scan() core.log("Created %s", dir_path) end, common.path_suggest) end, @@ -526,7 +523,6 @@ command.add(function() return view.hovered_item ~= nil end, { return end end - core.reschedule_project_scan() core.log("Deleted \"%s\"", filename) end end diff --git a/docs/api/renderer.lua b/docs/api/renderer.lua index bb622131..7a9b636d 100644 --- a/docs/api/renderer.lua +++ b/docs/api/renderer.lua @@ -19,6 +19,9 @@ renderer.color = {} ---@class renderer.fontoptions ---@field public antialiasing "'grayscale'" | "'subpixel'" ---@field public hinting "'slight'" | "'none'" | '"full"' +-- @field public bold boolean +-- @field public italic boolean +-- @field public underline boolean renderer.fontoptions = {} --- @@ -58,15 +61,6 @@ function renderer.font:set_tab_size(chars) end ---@return number function renderer.font:get_width(text) end ---- ----Get the width in subpixels of the given text when ----rendered with this font. ---- ----@param text string ---- ----@return number -function renderer.font:get_width_subpixel(text) end - --- ---Get the height in pixels that occupies a single character ---when rendered with this font. @@ -74,12 +68,6 @@ function renderer.font:get_width_subpixel(text) end ---@return number function renderer.font:get_height() end ---- ----Gets the font subpixel scale. ---- ----@return number -function renderer.font:subpixel_scale() end - --- ---Get the current size of the font. --- diff --git a/lib/dmon/dmon.h b/lib/dmon/dmon.h new file mode 100644 index 00000000..2bc9e0c3 --- /dev/null +++ b/lib/dmon/dmon.h @@ -0,0 +1,1591 @@ +#ifndef __DMON_H__ +#define __DMON_H__ + +// +// Copyright 2021 Sepehr Taghdisian (septag@github). All rights reserved. +// License: https://github.com/septag/dmon#license-bsd-2-clause +// +// Portable directory monitoring library +// watches directories for file or directory changes. +// +// Usage: +// define DMON_IMPL and include this file to use it: +// #define DMON_IMPL +// #include "dmon.h" +// +// dmon_init(): +// Call this once at the start of your program. +// This will start a low-priority monitoring thread +// dmon_deinit(): +// Call this when your work with dmon is finished, usually on program terminate +// This will free resources and stop the monitoring thread +// dmon_watch: +// Watch for directories +// You can watch multiple directories by calling this function multiple times +// rootdir: root directory to monitor +// watch_cb: callback function to receive events. +// NOTE that this function is called from another thread, so you should +// beware of data races in your application when accessing data within this +// callback +// flags: watch flags, see dmon_watch_flags_t +// user_data: user pointer that is passed to callback function +// Returns the Id of the watched directory after successful call, or returns Id=0 if error +// dmon_unwatch: +// Remove the directory from watch list +// +// see test.c for the basic example +// +// Configuration: +// You can customize some low-level functionality like malloc and logging by overriding macros: +// +// DMON_MALLOC, DMON_FREE, DMON_REALLOC: +// define these macros to override memory allocations +// default is 'malloc', 'free' and 'realloc' +// DMON_ASSERT: +// define this to provide your own assert +// default is 'assert' +// DMON_LOG_ERROR: +// define this to provide your own logging mechanism +// default implementation logs to stdout and breaks the program +// DMON_LOG_DEBUG +// define this to provide your own extra debug logging mechanism +// default implementation logs to stdout in DEBUG and does nothing in other builds +// DMON_API_DECL, DMON_API_IMPL +// define these to provide your own API declerations. (for example: static) +// default is nothing (which is extern in C language ) +// DMON_MAX_PATH +// Maximum size of path characters +// default is 260 characters +// DMON_MAX_WATCHES +// Maximum number of watch directories +// default is 64 +// +// TODO: +// - DMON_WATCHFLAGS_FOLLOW_SYMLINKS does not resolve files +// - implement DMON_WATCHFLAGS_OUTOFSCOPE_LINKS +// - implement DMON_WATCHFLAGS_IGNORE_DIRECTORIES +// +// History: +// 1.0.0 First version. working Win32/Linux backends +// 1.1.0 MacOS backend +// 1.1.1 Minor fixes, eliminate gcc/clang warnings with -Wall +// 1.1.2 Eliminate some win32 dead code +// 1.1.3 Fixed select not resetting causing high cpu usage on linux +// 1.2.1 inotify (linux) fixes and improvements, added extra functionality header for linux +// to manually add/remove directories manually to the watch handle, in case of large file sets +// + +#include +#include + +#ifndef DMON_API_DECL +# define DMON_API_DECL +#endif + +#ifndef DMON_API_IMPL +# define DMON_API_IMPL +#endif + +typedef struct { uint32_t id; } dmon_watch_id; + +// Pass these flags to `dmon_watch` +typedef enum dmon_watch_flags_t { + DMON_WATCHFLAGS_RECURSIVE = 0x1, // monitor all child directories + DMON_WATCHFLAGS_FOLLOW_SYMLINKS = 0x2, // resolve symlinks (linux only) + DMON_WATCHFLAGS_OUTOFSCOPE_LINKS = 0x4, // TODO: not implemented yet + DMON_WATCHFLAGS_IGNORE_DIRECTORIES = 0x8 // TODO: not implemented yet +} dmon_watch_flags; + +// Action is what operation performed on the file. this value is provided by watch callback +typedef enum dmon_action_t { + DMON_ACTION_CREATE = 1, + DMON_ACTION_DELETE, + DMON_ACTION_MODIFY, + DMON_ACTION_MOVE +} dmon_action; + +#ifdef __cplusplus +extern "C" { +#endif + +DMON_API_DECL void dmon_init(void); +DMON_API_DECL void dmon_deinit(void); + +DMON_API_DECL dmon_watch_id dmon_watch(const char* rootdir, + void (*watch_cb)(dmon_watch_id watch_id, dmon_action action, + const char* rootdir, const char* filepath, + const char* oldfilepath, void* user), + uint32_t flags, void* user_data); +DMON_API_DECL void dmon_unwatch(dmon_watch_id id); + +#ifdef __cplusplus +} +#endif + +#ifdef DMON_IMPL + +#define DMON_OS_WINDOWS 0 +#define DMON_OS_MACOS 0 +#define DMON_OS_LINUX 0 + +#if defined(_WIN32) || defined(_WIN64) +# undef DMON_OS_WINDOWS +# define DMON_OS_WINDOWS 1 +#elif defined(__linux__) +# undef DMON_OS_LINUX +# define DMON_OS_LINUX 1 +#elif defined(__ENVIRONMENT_MAC_OS_X_VERSION_MIN_REQUIRED__) +# undef DMON_OS_MACOS +# define DMON_OS_MACOS __ENVIRONMENT_MAC_OS_X_VERSION_MIN_REQUIRED__ +#else +# define DMON_OS 0 +# error "unsupported platform" +#endif + +#if DMON_OS_WINDOWS +# ifndef WIN32_LEAN_AND_MEAN +# define WIN32_LEAN_AND_MEAN +# endif +# ifndef NOMINMAX +# define NOMINMAX +# endif +# include +# include +# ifdef _MSC_VER +# pragma intrinsic(_InterlockedExchange) +# endif +#elif DMON_OS_LINUX +# ifndef __USE_MISC +# define __USE_MISC +# endif +# include +# include +# include +# include +# include +# include +# include +# include +# include +# include +# include +#elif DMON_OS_MACOS +# include +# include +# include +# include +# include +#endif + +#ifndef DMON_MALLOC +# include +# define DMON_MALLOC(size) malloc(size) +# define DMON_FREE(ptr) free(ptr) +# define DMON_REALLOC(ptr, size) realloc(ptr, size) +#endif + +#ifndef DMON_ASSERT +# include +# define DMON_ASSERT(e) assert(e) +#endif + +#ifndef DMON_LOG_ERROR +# include +# define DMON_LOG_ERROR(s) do { puts(s); DMON_ASSERT(0); } while(0) +#endif + +#ifndef DMON_LOG_DEBUG +# ifndef NDEBUG +# include +# define DMON_LOG_DEBUG(s) do { puts(s); } while(0) +# else +# define DMON_LOG_DEBUG(s) +# endif +#endif + +#ifndef DMON_MAX_WATCHES +# define DMON_MAX_WATCHES 64 +#endif + +#ifndef DMON_MAX_PATH +# define DMON_MAX_PATH 260 +#endif + +#define _DMON_UNUSED(x) (void)(x) + +#ifndef _DMON_PRIVATE +# if defined(__GNUC__) || defined(__clang__) +# define _DMON_PRIVATE __attribute__((unused)) static +# else +# define _DMON_PRIVATE static +# endif +#endif + +#include + +#ifndef _DMON_LOG_ERRORF +# define _DMON_LOG_ERRORF(str, ...) do { char msg[512]; snprintf(msg, sizeof(msg), str, __VA_ARGS__); DMON_LOG_ERROR(msg); } while(0); +#endif + +#ifndef _DMON_LOG_DEBUGF +# define _DMON_LOG_DEBUGF(str, ...) do { char msg[512]; snprintf(msg, sizeof(msg), str, __VA_ARGS__); DMON_LOG_DEBUG(msg); } while(0); +#endif + +#ifndef dmon__min +# define dmon__min(a, b) ((a) < (b) ? (a) : (b)) +#endif + +#ifndef dmon__max +# define dmon__max(a, b) ((a) > (b) ? (a) : (b)) +#endif + +#ifndef dmon__swap +# define dmon__swap(a, b, _type) \ + do { \ + _type tmp = a; \ + a = b; \ + b = tmp; \ + } while (0) +#endif + +#ifndef dmon__make_id +# ifdef __cplusplus +# define dmon__make_id(id) {id} +# else +# define dmon__make_id(id) (dmon_watch_id) {id} +# endif +#endif // dmon__make_id + +_DMON_PRIVATE bool dmon__isrange(char ch, char from, char to) +{ + return (uint8_t)(ch - from) <= (uint8_t)(to - from); +} + +_DMON_PRIVATE bool dmon__isupperchar(char ch) +{ + return dmon__isrange(ch, 'A', 'Z'); +} + +_DMON_PRIVATE char dmon__tolowerchar(char ch) +{ + return ch + (dmon__isupperchar(ch) ? 0x20 : 0); +} + +_DMON_PRIVATE char* dmon__tolower(char* dst, int dst_sz, const char* str) +{ + int offset = 0; + int dst_max = dst_sz - 1; + while (*str && offset < dst_max) { + dst[offset++] = dmon__tolowerchar(*str); + ++str; + } + dst[offset] = '\0'; + return dst; +} + +_DMON_PRIVATE char* dmon__strcpy(char* dst, int dst_sz, const char* src) +{ + DMON_ASSERT(dst); + DMON_ASSERT(src); + + const int32_t len = (int32_t)strlen(src); + const int32_t _max = dst_sz - 1; + const int32_t num = (len < _max ? len : _max); + memcpy(dst, src, num); + dst[num] = '\0'; + + return dst; +} + +_DMON_PRIVATE char* dmon__unixpath(char* dst, int size, const char* path) +{ + size_t len = strlen(path); + len = dmon__min(len, (size_t)size - 1); + + for (size_t i = 0; i < len; i++) { + if (path[i] != '\\') + dst[i] = path[i]; + else + dst[i] = '/'; + } + dst[len] = '\0'; + return dst; +} + +#if DMON_OS_LINUX || DMON_OS_MACOS +_DMON_PRIVATE char* dmon__strcat(char* dst, int dst_sz, const char* src) +{ + int len = (int)strlen(dst); + return dmon__strcpy(dst + len, dst_sz - len, src); +} +#endif // DMON_OS_LINUX || DMON_OS_MACOS + +// stretchy buffer: https://github.com/nothings/stb/blob/master/stretchy_buffer.h +#define stb_sb_free(a) ((a) ? DMON_FREE(stb__sbraw(a)),0 : 0) +#define stb_sb_push(a,v) (stb__sbmaybegrow(a,1), (a)[stb__sbn(a)++] = (v)) +#define stb_sb_pop(a) (stb__sbn(a)--) +#define stb_sb_count(a) ((a) ? stb__sbn(a) : 0) +#define stb_sb_add(a,n) (stb__sbmaybegrow(a,n), stb__sbn(a)+=(n), &(a)[stb__sbn(a)-(n)]) +#define stb_sb_last(a) ((a)[stb__sbn(a)-1]) +#define stb_sb_reset(a) ((a) ? (stb__sbn(a) = 0) : 0) + +#define stb__sbraw(a) ((int *) (a) - 2) +#define stb__sbm(a) stb__sbraw(a)[0] +#define stb__sbn(a) stb__sbraw(a)[1] + +#define stb__sbneedgrow(a,n) ((a)==0 || stb__sbn(a)+(n) >= stb__sbm(a)) +#define stb__sbmaybegrow(a,n) (stb__sbneedgrow(a,(n)) ? stb__sbgrow(a,n) : 0) +#define stb__sbgrow(a,n) (*((void **)&(a)) = stb__sbgrowf((a), (n), sizeof(*(a)))) + +static void * stb__sbgrowf(void *arr, int increment, int itemsize) +{ + int dbl_cur = arr ? 2*stb__sbm(arr) : 0; + int min_needed = stb_sb_count(arr) + increment; + int m = dbl_cur > min_needed ? dbl_cur : min_needed; + int *p = (int *) DMON_REALLOC(arr ? stb__sbraw(arr) : 0, itemsize * m + sizeof(int)*2); + if (p) { + if (!arr) + p[1] = 0; + p[0] = m; + return p+2; + } else { + return (void *) (2*sizeof(int)); // try to force a NULL pointer exception later + } +} + +// watcher callback (same as dmon.h's decleration) +typedef void (dmon__watch_cb)(dmon_watch_id, dmon_action, const char*, const char*, const char*, void*); + +#if DMON_OS_WINDOWS +// IOCP (windows) +#ifdef UNICODE +# define _DMON_WINAPI_STR(name, size) wchar_t _##name[size]; MultiByteToWideChar(CP_UTF8, 0, name, -1, _##name, size) +#else +# define _DMON_WINAPI_STR(name, size) const char* _##name = name +#endif + +typedef struct dmon__win32_event { + char filepath[DMON_MAX_PATH]; + DWORD action; + dmon_watch_id watch_id; + bool skip; +} dmon__win32_event; + +typedef struct dmon__watch_state { + dmon_watch_id id; + OVERLAPPED overlapped; + HANDLE dir_handle; + uint8_t buffer[64512]; // http://msdn.microsoft.com/en-us/library/windows/desktop/aa365465(v=vs.85).aspx + DWORD notify_filter; + dmon__watch_cb* watch_cb; + uint32_t watch_flags; + void* user_data; + char rootdir[DMON_MAX_PATH]; + char old_filepath[DMON_MAX_PATH]; +} dmon__watch_state; + +typedef struct dmon__state { + int num_watches; + dmon__watch_state watches[DMON_MAX_WATCHES]; + HANDLE thread_handle; + CRITICAL_SECTION mutex; + volatile LONG modify_watches; + dmon__win32_event* events; + bool quit; +} dmon__state; + +static bool _dmon_init; +static dmon__state _dmon; + +_DMON_PRIVATE bool dmon__refresh_watch(dmon__watch_state* watch) +{ + return ReadDirectoryChangesW(watch->dir_handle, watch->buffer, sizeof(watch->buffer), + (watch->watch_flags & DMON_WATCHFLAGS_RECURSIVE) ? TRUE : FALSE, + watch->notify_filter, NULL, &watch->overlapped, NULL) != 0; +} + +_DMON_PRIVATE void dmon__unwatch(dmon__watch_state* watch) +{ + CancelIo(watch->dir_handle); + CloseHandle(watch->overlapped.hEvent); + CloseHandle(watch->dir_handle); + memset(watch, 0x0, sizeof(dmon__watch_state)); +} + +_DMON_PRIVATE void dmon__win32_process_events(void) +{ + for (int i = 0, c = stb_sb_count(_dmon.events); i < c; i++) { + dmon__win32_event* ev = &_dmon.events[i]; + if (ev->skip) { + continue; + } + + if (ev->action == FILE_ACTION_MODIFIED || ev->action == FILE_ACTION_ADDED) { + // remove duplicate modifies on a single file + for (int j = i + 1; j < c; j++) { + dmon__win32_event* check_ev = &_dmon.events[j]; + if (check_ev->action == FILE_ACTION_MODIFIED && + strcmp(ev->filepath, check_ev->filepath) == 0) { + check_ev->skip = true; + } + } + } + } + + // trigger user callbacks + for (int i = 0, c = stb_sb_count(_dmon.events); i < c; i++) { + dmon__win32_event* ev = &_dmon.events[i]; + if (ev->skip) { + continue; + } + dmon__watch_state* watch = &_dmon.watches[ev->watch_id.id - 1]; + + if(watch == NULL || watch->watch_cb == NULL) { + continue; + } + + switch (ev->action) { + case FILE_ACTION_ADDED: + watch->watch_cb(ev->watch_id, DMON_ACTION_CREATE, watch->rootdir, ev->filepath, NULL, + watch->user_data); + break; + case FILE_ACTION_MODIFIED: + watch->watch_cb(ev->watch_id, DMON_ACTION_MODIFY, watch->rootdir, ev->filepath, NULL, + watch->user_data); + break; + case FILE_ACTION_RENAMED_OLD_NAME: { + // find the first occurance of the NEW_NAME + // this is somewhat API flaw that we have no reference for relating old and new files + for (int j = i + 1; j < c; j++) { + dmon__win32_event* check_ev = &_dmon.events[j]; + if (check_ev->action == FILE_ACTION_RENAMED_NEW_NAME) { + watch->watch_cb(check_ev->watch_id, DMON_ACTION_MOVE, watch->rootdir, + check_ev->filepath, ev->filepath, watch->user_data); + break; + } + } + } break; + case FILE_ACTION_REMOVED: + watch->watch_cb(ev->watch_id, DMON_ACTION_DELETE, watch->rootdir, ev->filepath, NULL, + watch->user_data); + break; + } + } + stb_sb_reset(_dmon.events); +} + +_DMON_PRIVATE DWORD WINAPI dmon__thread(LPVOID arg) +{ + _DMON_UNUSED(arg); + HANDLE wait_handles[DMON_MAX_WATCHES]; + + SYSTEMTIME starttm; + GetSystemTime(&starttm); + uint64_t msecs_elapsed = 0; + + while (!_dmon.quit) { + if (_dmon.modify_watches || !TryEnterCriticalSection(&_dmon.mutex)) { + Sleep(10); + continue; + } + + if (_dmon.num_watches == 0) { + Sleep(10); + LeaveCriticalSection(&_dmon.mutex); + continue; + } + + for (int i = 0; i < _dmon.num_watches; i++) { + dmon__watch_state* watch = &_dmon.watches[i]; + wait_handles[i] = watch->overlapped.hEvent; + } + + DWORD wait_result = WaitForMultipleObjects(_dmon.num_watches, wait_handles, FALSE, 10); + DMON_ASSERT(wait_result != WAIT_FAILED); + if (wait_result != WAIT_TIMEOUT) { + dmon__watch_state* watch = &_dmon.watches[wait_result - WAIT_OBJECT_0]; + DMON_ASSERT(HasOverlappedIoCompleted(&watch->overlapped)); + + DWORD bytes; + if (GetOverlappedResult(watch->dir_handle, &watch->overlapped, &bytes, FALSE)) { + char filepath[DMON_MAX_PATH]; + PFILE_NOTIFY_INFORMATION notify; + size_t offset = 0; + + if (bytes == 0) { + dmon__refresh_watch(watch); + LeaveCriticalSection(&_dmon.mutex); + continue; + } + + do { + notify = (PFILE_NOTIFY_INFORMATION)&watch->buffer[offset]; + + int count = WideCharToMultiByte(CP_UTF8, 0, notify->FileName, + notify->FileNameLength / sizeof(WCHAR), + filepath, DMON_MAX_PATH - 1, NULL, NULL); + filepath[count] = TEXT('\0'); + dmon__unixpath(filepath, sizeof(filepath), filepath); + + // TODO: ignore directories if flag is set + + if (stb_sb_count(_dmon.events) == 0) { + msecs_elapsed = 0; + } + dmon__win32_event wev = { { 0 }, notify->Action, watch->id, false }; + dmon__strcpy(wev.filepath, sizeof(wev.filepath), filepath); + stb_sb_push(_dmon.events, wev); + + offset += notify->NextEntryOffset; + } while (notify->NextEntryOffset > 0); + + if (!_dmon.quit) { + dmon__refresh_watch(watch); + } + } + } // if (WaitForMultipleObjects) + + SYSTEMTIME tm; + GetSystemTime(&tm); + LONG dt = + (tm.wSecond - starttm.wSecond) * 1000 + (tm.wMilliseconds - starttm.wMilliseconds); + starttm = tm; + msecs_elapsed += dt; + if (msecs_elapsed > 100 && stb_sb_count(_dmon.events) > 0) { + dmon__win32_process_events(); + msecs_elapsed = 0; + } + + LeaveCriticalSection(&_dmon.mutex); + } + return 0; +} + + +DMON_API_IMPL void dmon_init(void) +{ + DMON_ASSERT(!_dmon_init); + InitializeCriticalSection(&_dmon.mutex); + + _dmon.thread_handle = + CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)dmon__thread, NULL, 0, NULL); + DMON_ASSERT(_dmon.thread_handle); + _dmon_init = true; +} + + +DMON_API_IMPL void dmon_deinit(void) +{ + DMON_ASSERT(_dmon_init); + _dmon.quit = true; + if (_dmon.thread_handle != INVALID_HANDLE_VALUE) { + WaitForSingleObject(_dmon.thread_handle, INFINITE); + CloseHandle(_dmon.thread_handle); + } + + for (int i = 0; i < _dmon.num_watches; i++) { + dmon__unwatch(&_dmon.watches[i]); + } + + DeleteCriticalSection(&_dmon.mutex); + stb_sb_free(_dmon.events); + _dmon_init = false; +} + +DMON_API_IMPL dmon_watch_id dmon_watch(const char* rootdir, + void (*watch_cb)(dmon_watch_id watch_id, dmon_action action, + const char* dirname, const char* filename, + const char* oldname, void* user), + uint32_t flags, void* user_data) +{ + DMON_ASSERT(watch_cb); + DMON_ASSERT(rootdir && rootdir[0]); + + _InterlockedExchange(&_dmon.modify_watches, 1); + EnterCriticalSection(&_dmon.mutex); + + DMON_ASSERT(_dmon.num_watches < DMON_MAX_WATCHES); + + uint32_t id = ++_dmon.num_watches; + dmon__watch_state* watch = &_dmon.watches[id - 1]; + watch->id = dmon__make_id(id); + watch->watch_flags = flags; + watch->watch_cb = watch_cb; + watch->user_data = user_data; + + dmon__strcpy(watch->rootdir, sizeof(watch->rootdir) - 1, rootdir); + dmon__unixpath(watch->rootdir, sizeof(watch->rootdir), rootdir); + size_t rootdir_len = strlen(watch->rootdir); + if (watch->rootdir[rootdir_len - 1] != '/') { + watch->rootdir[rootdir_len] = '/'; + watch->rootdir[rootdir_len + 1] = '\0'; + } + + _DMON_WINAPI_STR(rootdir, DMON_MAX_PATH); + watch->dir_handle = + CreateFile(_rootdir, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED, NULL); + if (watch->dir_handle != INVALID_HANDLE_VALUE) { + watch->notify_filter = FILE_NOTIFY_CHANGE_CREATION | FILE_NOTIFY_CHANGE_LAST_WRITE | + FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_DIR_NAME | + FILE_NOTIFY_CHANGE_SIZE; + watch->overlapped.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL); + DMON_ASSERT(watch->overlapped.hEvent != INVALID_HANDLE_VALUE); + + if (!dmon__refresh_watch(watch)) { + dmon__unwatch(watch); + DMON_LOG_ERROR("ReadDirectoryChanges failed"); + LeaveCriticalSection(&_dmon.mutex); + _InterlockedExchange(&_dmon.modify_watches, 0); + return dmon__make_id(0); + } + } else { + _DMON_LOG_ERRORF("Could not open: %s", rootdir); + LeaveCriticalSection(&_dmon.mutex); + _InterlockedExchange(&_dmon.modify_watches, 0); + return dmon__make_id(0); + } + + LeaveCriticalSection(&_dmon.mutex); + _InterlockedExchange(&_dmon.modify_watches, 0); + return dmon__make_id(id); +} + +DMON_API_IMPL void dmon_unwatch(dmon_watch_id id) +{ + DMON_ASSERT(id.id > 0); + + _InterlockedExchange(&_dmon.modify_watches, 1); + EnterCriticalSection(&_dmon.mutex); + + int index = id.id - 1; + DMON_ASSERT(index < _dmon.num_watches); + + dmon__unwatch(&_dmon.watches[index]); + if (index != _dmon.num_watches - 1) { + dmon__swap(_dmon.watches[index], _dmon.watches[_dmon.num_watches - 1], dmon__watch_state); + } + --_dmon.num_watches; + + LeaveCriticalSection(&_dmon.mutex); + _InterlockedExchange(&_dmon.modify_watches, 0); +} + +#elif DMON_OS_LINUX +// inotify linux backend +#define _DMON_TEMP_BUFFSIZE ((sizeof(struct inotify_event) + PATH_MAX) * 1024) + +typedef struct dmon__watch_subdir { + char rootdir[DMON_MAX_PATH]; +} dmon__watch_subdir; + +typedef struct dmon__inotify_event { + char filepath[DMON_MAX_PATH]; + uint32_t mask; + uint32_t cookie; + dmon_watch_id watch_id; + bool skip; +} dmon__inotify_event; + +typedef struct dmon__watch_state { + dmon_watch_id id; + int fd; + uint32_t watch_flags; + dmon__watch_cb* watch_cb; + void* user_data; + char rootdir[DMON_MAX_PATH]; + dmon__watch_subdir* subdirs; + int* wds; +} dmon__watch_state; + +typedef struct dmon__state { + dmon__watch_state watches[DMON_MAX_WATCHES]; + dmon__inotify_event* events; + int num_watches; + pthread_t thread_handle; + pthread_mutex_t mutex; + bool quit; +} dmon__state; + +static bool _dmon_init; +static dmon__state _dmon; + +_DMON_PRIVATE void dmon__watch_recursive(const char* dirname, int fd, uint32_t mask, + bool followlinks, dmon__watch_state* watch) +{ + struct dirent* entry; + DIR* dir = opendir(dirname); + DMON_ASSERT(dir); + + char watchdir[DMON_MAX_PATH]; + + while ((entry = readdir(dir)) != NULL) { + bool entry_valid = false; + if (entry->d_type == DT_DIR) { + if (strcmp(entry->d_name, "..") != 0 && strcmp(entry->d_name, ".") != 0) { + dmon__strcpy(watchdir, sizeof(watchdir), dirname); + dmon__strcat(watchdir, sizeof(watchdir), entry->d_name); + entry_valid = true; + } + } else if (followlinks && entry->d_type == DT_LNK) { + char linkpath[PATH_MAX]; + dmon__strcpy(watchdir, sizeof(watchdir), dirname); + dmon__strcat(watchdir, sizeof(watchdir), entry->d_name); + char* r = realpath(watchdir, linkpath); + _DMON_UNUSED(r); + DMON_ASSERT(r); + dmon__strcpy(watchdir, sizeof(watchdir), linkpath); + entry_valid = true; + } + + // add sub-directory to watch dirs + if (entry_valid) { + int watchdir_len = (int)strlen(watchdir); + if (watchdir[watchdir_len - 1] != '/') { + watchdir[watchdir_len] = '/'; + watchdir[watchdir_len + 1] = '\0'; + } + int wd = inotify_add_watch(fd, watchdir, mask); + _DMON_UNUSED(wd); + DMON_ASSERT(wd != -1); + + dmon__watch_subdir subdir; + dmon__strcpy(subdir.rootdir, sizeof(subdir.rootdir), watchdir); + if (strstr(subdir.rootdir, watch->rootdir) == subdir.rootdir) { + dmon__strcpy(subdir.rootdir, sizeof(subdir.rootdir), watchdir + strlen(watch->rootdir)); + } + + stb_sb_push(watch->subdirs, subdir); + stb_sb_push(watch->wds, wd); + + // recurse + dmon__watch_recursive(watchdir, fd, mask, followlinks, watch); + } + } + closedir(dir); +} + +_DMON_PRIVATE const char* dmon__find_subdir(const dmon__watch_state* watch, int wd) +{ + const int* wds = watch->wds; + for (int i = 0, c = stb_sb_count(wds); i < c; i++) { + if (wd == wds[i]) { + return watch->subdirs[i].rootdir; + } + } + + return NULL; +} + +_DMON_PRIVATE void dmon__gather_recursive(dmon__watch_state* watch, const char* dirname) +{ + struct dirent* entry; + DIR* dir = opendir(dirname); + DMON_ASSERT(dir); + + char newdir[DMON_MAX_PATH]; + while ((entry = readdir(dir)) != NULL) { + bool entry_valid = false; + bool is_dir = false; + if (strcmp(entry->d_name, "..") != 0 && strcmp(entry->d_name, ".") != 0) { + dmon__strcpy(newdir, sizeof(newdir), dirname); + dmon__strcat(newdir, sizeof(newdir), entry->d_name); + is_dir = (entry->d_type == DT_DIR); + entry_valid = true; + } + + // add sub-directory to watch dirs + if (entry_valid) { + dmon__watch_subdir subdir; + dmon__strcpy(subdir.rootdir, sizeof(subdir.rootdir), newdir); + if (strstr(subdir.rootdir, watch->rootdir) == subdir.rootdir) { + dmon__strcpy(subdir.rootdir, sizeof(subdir.rootdir), newdir + strlen(watch->rootdir)); + } + + dmon__inotify_event dev = { { 0 }, IN_CREATE|(is_dir ? IN_ISDIR : 0), 0, watch->id, false }; + dmon__strcpy(dev.filepath, sizeof(dev.filepath), subdir.rootdir); + stb_sb_push(_dmon.events, dev); + } + } + closedir(dir); +} + +_DMON_PRIVATE void dmon__inotify_process_events(void) +{ + for (int i = 0, c = stb_sb_count(_dmon.events); i < c; i++) { + dmon__inotify_event* ev = &_dmon.events[i]; + if (ev->skip) { + continue; + } + + // remove redundant modify events on a single file + if (ev->mask & IN_MODIFY) { + for (int j = i + 1; j < c; j++) { + dmon__inotify_event* check_ev = &_dmon.events[j]; + if ((check_ev->mask & IN_MODIFY) && strcmp(ev->filepath, check_ev->filepath) == 0) { + ev->skip = true; + break; + } else if ((ev->mask & IN_ISDIR) && (check_ev->mask & (IN_ISDIR|IN_MODIFY))) { + // in some cases, particularly when created files under sub directories + // there can be two modify events for a single subdir one with trailing slash and one without + // remove traling slash from both cases and test + int l1 = (int)strlen(ev->filepath); + int l2 = (int)strlen(check_ev->filepath); + if (ev->filepath[l1-1] == '/') ev->filepath[l1-1] = '\0'; + if (check_ev->filepath[l2-1] == '/') check_ev->filepath[l2-1] = '\0'; + if (strcmp(ev->filepath, check_ev->filepath) == 0) { + ev->skip = true; + break; + } + } + } + } else if (ev->mask & IN_CREATE) { + bool loop_break = false; + for (int j = i + 1; j < c && !loop_break; j++) { + dmon__inotify_event* check_ev = &_dmon.events[j]; + if ((check_ev->mask & IN_MOVED_FROM) && strcmp(ev->filepath, check_ev->filepath) == 0) { + // there is a case where some programs (like gedit): + // when we save, it creates a temp file, and moves it to the file being modified + // search for these cases and remove all of them + for (int k = j + 1; k < c; k++) { + dmon__inotify_event* third_ev = &_dmon.events[k]; + if (third_ev->mask & IN_MOVED_TO && check_ev->cookie == third_ev->cookie) { + third_ev->mask = IN_MODIFY; // change to modified + ev->skip = check_ev->skip = true; + loop_break = true; + break; + } + } + } else if ((check_ev->mask & IN_MODIFY) && strcmp(ev->filepath, check_ev->filepath) == 0) { + // Another case is that file is copied. CREATE and MODIFY happens sequentially + // so we ignore MODIFY event + check_ev->skip = true; + } + } + } else if (ev->mask & IN_MOVED_FROM) { + bool move_valid = false; + for (int j = i + 1; j < c; j++) { + dmon__inotify_event* check_ev = &_dmon.events[j]; + if (check_ev->mask & IN_MOVED_TO && ev->cookie == check_ev->cookie) { + move_valid = true; + break; + } + } + + // in some environments like nautilus file explorer: + // when a file is deleted, it is moved to recycle bin + // so if the destination of the move is not valid, it's probably DELETE + if (!move_valid) { + ev->mask = IN_DELETE; + } + } else if (ev->mask & IN_MOVED_TO) { + bool move_valid = false; + for (int j = 0; j < i; j++) { + dmon__inotify_event* check_ev = &_dmon.events[j]; + if (check_ev->mask & IN_MOVED_FROM && ev->cookie == check_ev->cookie) { + move_valid = true; + break; + } + } + + // in some environments like nautilus file explorer: + // when a file is deleted, it is moved to recycle bin, on undo it is moved back it + // so if the destination of the move is not valid, it's probably CREATE + if (!move_valid) { + ev->mask = IN_CREATE; + } + } else if (ev->mask & IN_DELETE) { + for (int j = i + 1; j < c; j++) { + dmon__inotify_event* check_ev = &_dmon.events[j]; + // if the file is DELETED and then MODIFIED after, just ignore the modify event + if ((check_ev->mask & IN_MODIFY) && strcmp(ev->filepath, check_ev->filepath) == 0) { + check_ev->skip = true; + break; + } + } + } + } + + // trigger user callbacks + for (int i = 0; i < stb_sb_count(_dmon.events); i++) { + dmon__inotify_event* ev = &_dmon.events[i]; + if (ev->skip) { + continue; + } + dmon__watch_state* watch = &_dmon.watches[ev->watch_id.id - 1]; + + if(watch == NULL || watch->watch_cb == NULL) { + continue; + } + + if (ev->mask & IN_CREATE) { + if (ev->mask & IN_ISDIR) { + if (watch->watch_flags & DMON_WATCHFLAGS_RECURSIVE) { + char watchdir[DMON_MAX_PATH]; + dmon__strcpy(watchdir, sizeof(watchdir), watch->rootdir); + dmon__strcat(watchdir, sizeof(watchdir), ev->filepath); + dmon__strcat(watchdir, sizeof(watchdir), "/"); + uint32_t mask = IN_MOVED_TO | IN_CREATE | IN_MOVED_FROM | IN_DELETE | IN_MODIFY; + int wd = inotify_add_watch(watch->fd, watchdir, mask); + // Removing the assertion below because it was giving errors for some reason + // when building a new package. + // _DMON_UNUSED(wd); + // DMON_ASSERT(wd != -1); + if (wd == -1) continue; + + dmon__watch_subdir subdir; + dmon__strcpy(subdir.rootdir, sizeof(subdir.rootdir), watchdir); + if (strstr(subdir.rootdir, watch->rootdir) == subdir.rootdir) { + dmon__strcpy(subdir.rootdir, sizeof(subdir.rootdir), watchdir + strlen(watch->rootdir)); + } + + stb_sb_push(watch->subdirs, subdir); + stb_sb_push(watch->wds, wd); + + // some directories may be already created, for instance, with the command: mkdir -p + // so we will enumerate them manually and add them to the events + dmon__gather_recursive(watch, watchdir); + ev = &_dmon.events[i]; // gotta refresh the pointer because it may be relocated + } + } + watch->watch_cb(ev->watch_id, DMON_ACTION_CREATE, watch->rootdir, ev->filepath, NULL, watch->user_data); + } + else if (ev->mask & IN_MODIFY) { + watch->watch_cb(ev->watch_id, DMON_ACTION_MODIFY, watch->rootdir, ev->filepath, NULL, watch->user_data); + } + else if (ev->mask & IN_MOVED_FROM) { + for (int j = i + 1; j < stb_sb_count(_dmon.events); j++) { + dmon__inotify_event* check_ev = &_dmon.events[j]; + if (check_ev->mask & IN_MOVED_TO && ev->cookie == check_ev->cookie) { + watch->watch_cb(check_ev->watch_id, DMON_ACTION_MOVE, watch->rootdir, + check_ev->filepath, ev->filepath, watch->user_data); + break; + } + } + } + else if (ev->mask & IN_DELETE) { + watch->watch_cb(ev->watch_id, DMON_ACTION_DELETE, watch->rootdir, ev->filepath, NULL, watch->user_data); + } + } + + stb_sb_reset(_dmon.events); +} + +static void* dmon__thread(void* arg) +{ + _DMON_UNUSED(arg); + + static uint8_t buff[_DMON_TEMP_BUFFSIZE]; + struct timespec req = { (time_t)10 / 1000, (long)(10 * 1000000) }; + struct timespec rem = { 0, 0 }; + struct timeval timeout; + uint64_t usecs_elapsed = 0; + + struct timeval starttm; + gettimeofday(&starttm, 0); + + while (!_dmon.quit) { + nanosleep(&req, &rem); + if (_dmon.num_watches == 0 || pthread_mutex_trylock(&_dmon.mutex) != 0) { + continue; + } + + // Create read FD set + fd_set rfds; + FD_ZERO(&rfds); + for (int i = 0; i < _dmon.num_watches; i++) { + dmon__watch_state* watch = &_dmon.watches[i]; + FD_SET(watch->fd, &rfds); + } + + timeout.tv_sec = 0; + timeout.tv_usec = 100000; + if (select(FD_SETSIZE, &rfds, NULL, NULL, &timeout)) { + for (int i = 0; i < _dmon.num_watches; i++) { + dmon__watch_state* watch = &_dmon.watches[i]; + if (FD_ISSET(watch->fd, &rfds)) { + ssize_t offset = 0; + ssize_t len = read(watch->fd, buff, _DMON_TEMP_BUFFSIZE); + if (len <= 0) { + continue; + } + + while (offset < len) { + struct inotify_event* iev = (struct inotify_event*)&buff[offset]; + + const char *subdir = dmon__find_subdir(watch, iev->wd); + if (subdir) { + char filepath[DMON_MAX_PATH]; + dmon__strcpy(filepath, sizeof(filepath), subdir); + dmon__strcat(filepath, sizeof(filepath), iev->name); + + // TODO: ignore directories if flag is set + + if (stb_sb_count(_dmon.events) == 0) { + usecs_elapsed = 0; + } + dmon__inotify_event dev = { { 0 }, iev->mask, iev->cookie, watch->id, false }; + dmon__strcpy(dev.filepath, sizeof(dev.filepath), filepath); + stb_sb_push(_dmon.events, dev); + } + + offset += sizeof(struct inotify_event) + iev->len; + } + } + } + } + + struct timeval tm; + gettimeofday(&tm, 0); + long dt = (tm.tv_sec - starttm.tv_sec) * 1000000 + tm.tv_usec - starttm.tv_usec; + starttm = tm; + usecs_elapsed += dt; + if (usecs_elapsed > 100000 && stb_sb_count(_dmon.events) > 0) { + dmon__inotify_process_events(); + usecs_elapsed = 0; + } + + pthread_mutex_unlock(&_dmon.mutex); + } + return 0x0; +} + +_DMON_PRIVATE void dmon__unwatch(dmon__watch_state* watch) +{ + close(watch->fd); + stb_sb_free(watch->subdirs); + stb_sb_free(watch->wds); + memset(watch, 0x0, sizeof(dmon__watch_state)); +} + +DMON_API_IMPL void dmon_init(void) +{ + DMON_ASSERT(!_dmon_init); + pthread_mutex_init(&_dmon.mutex, NULL); + + int r = pthread_create(&_dmon.thread_handle, NULL, dmon__thread, NULL); + _DMON_UNUSED(r); + DMON_ASSERT(r == 0 && "pthread_create failed"); + _dmon_init = true; +} + +DMON_API_IMPL void dmon_deinit(void) +{ + DMON_ASSERT(_dmon_init); + _dmon.quit = true; + pthread_join(_dmon.thread_handle, NULL); + + for (int i = 0; i < _dmon.num_watches; i++) { + dmon__unwatch(&_dmon.watches[i]); + } + + pthread_mutex_destroy(&_dmon.mutex); + stb_sb_free(_dmon.events); + _dmon_init = false; +} + +DMON_API_IMPL dmon_watch_id dmon_watch(const char* rootdir, + void (*watch_cb)(dmon_watch_id watch_id, dmon_action action, + const char* dirname, const char* filename, + const char* oldname, void* user), + uint32_t flags, void* user_data) +{ + DMON_ASSERT(watch_cb); + DMON_ASSERT(rootdir && rootdir[0]); + + pthread_mutex_lock(&_dmon.mutex); + + DMON_ASSERT(_dmon.num_watches < DMON_MAX_WATCHES); + + uint32_t id = ++_dmon.num_watches; + dmon__watch_state* watch = &_dmon.watches[id - 1]; + watch->id = dmon__make_id(id); + watch->watch_flags = flags; + watch->watch_cb = watch_cb; + watch->user_data = user_data; + + struct stat root_st; + if (stat(rootdir, &root_st) != 0 || !S_ISDIR(root_st.st_mode) || + (root_st.st_mode & S_IRUSR) != S_IRUSR) { + _DMON_LOG_ERRORF("Could not open/read directory: %s", rootdir); + pthread_mutex_unlock(&_dmon.mutex); + return dmon__make_id(0); + } + + + if (S_ISLNK(root_st.st_mode)) { + if (flags & DMON_WATCHFLAGS_FOLLOW_SYMLINKS) { + char linkpath[PATH_MAX]; + char* r = realpath(rootdir, linkpath); + _DMON_UNUSED(r); + DMON_ASSERT(r); + + dmon__strcpy(watch->rootdir, sizeof(watch->rootdir) - 1, linkpath); + } else { + _DMON_LOG_ERRORF("symlinks are unsupported: %s. use DMON_WATCHFLAGS_FOLLOW_SYMLINKS", + rootdir); + pthread_mutex_unlock(&_dmon.mutex); + return dmon__make_id(0); + } + } else { + dmon__strcpy(watch->rootdir, sizeof(watch->rootdir) - 1, rootdir); + } + + // add trailing slash + int rootdir_len = (int)strlen(watch->rootdir); + if (watch->rootdir[rootdir_len - 1] != '/') { + watch->rootdir[rootdir_len] = '/'; + watch->rootdir[rootdir_len + 1] = '\0'; + } + + watch->fd = inotify_init(); + if (watch->fd < -1) { + DMON_LOG_ERROR("could not create inotify instance"); + pthread_mutex_unlock(&_dmon.mutex); + return dmon__make_id(0); + } + + uint32_t inotify_mask = IN_MOVED_TO | IN_CREATE | IN_MOVED_FROM | IN_DELETE | IN_MODIFY; + int wd = inotify_add_watch(watch->fd, watch->rootdir, inotify_mask); + if (wd < 0) { + _DMON_LOG_ERRORF("Error watching directory '%s'. (inotify_add_watch:err=%d)", watch->rootdir, errno); + pthread_mutex_unlock(&_dmon.mutex); + return dmon__make_id(0); + } + dmon__watch_subdir subdir; + dmon__strcpy(subdir.rootdir, sizeof(subdir.rootdir), ""); // root dir is just a dummy entry + stb_sb_push(watch->subdirs, subdir); + stb_sb_push(watch->wds, wd); + + // recursive mode: enumarate all child directories and add them to watch + if (flags & DMON_WATCHFLAGS_RECURSIVE) { + dmon__watch_recursive(watch->rootdir, watch->fd, inotify_mask, + (flags & DMON_WATCHFLAGS_FOLLOW_SYMLINKS) ? true : false, watch); + } + + + pthread_mutex_unlock(&_dmon.mutex); + return dmon__make_id(id); +} + +DMON_API_IMPL void dmon_unwatch(dmon_watch_id id) +{ + DMON_ASSERT(id.id > 0); + + pthread_mutex_lock(&_dmon.mutex); + + int index = id.id - 1; + DMON_ASSERT(index < _dmon.num_watches); + + dmon__unwatch(&_dmon.watches[index]); + if (index != _dmon.num_watches - 1) { + dmon__swap(_dmon.watches[index], _dmon.watches[_dmon.num_watches - 1], dmon__watch_state); + } + --_dmon.num_watches; + + pthread_mutex_unlock(&_dmon.mutex); +} +#elif DMON_OS_MACOS +// FSEvents MacOS backend +typedef struct dmon__fsevent_event { + char filepath[DMON_MAX_PATH]; + uint64_t event_id; + long event_flags; + dmon_watch_id watch_id; + bool skip; + bool move_valid; +} dmon__fsevent_event; + +typedef struct dmon__watch_state { + dmon_watch_id id; + uint32_t watch_flags; + FSEventStreamRef fsev_stream_ref; + dmon__watch_cb* watch_cb; + void* user_data; + char rootdir[DMON_MAX_PATH]; + char rootdir_unmod[DMON_MAX_PATH]; + bool init; +} dmon__watch_state; + +typedef struct dmon__state { + dmon__watch_state watches[DMON_MAX_WATCHES]; + dmon__fsevent_event* events; + int num_watches; + volatile int modify_watches; + pthread_t thread_handle; + dispatch_semaphore_t thread_sem; + pthread_mutex_t mutex; + CFRunLoopRef cf_loop_ref; + CFAllocatorRef cf_alloc_ref; + bool quit; +} dmon__state; + +union dmon__cast_userdata { + void* ptr; + uint32_t id; +}; + +static bool _dmon_init; +static dmon__state _dmon; + +_DMON_PRIVATE void* dmon__cf_malloc(CFIndex size, CFOptionFlags hints, void* info) +{ + _DMON_UNUSED(hints); + _DMON_UNUSED(info); + return DMON_MALLOC(size); +} + +_DMON_PRIVATE void dmon__cf_free(void* ptr, void* info) +{ + _DMON_UNUSED(info); + DMON_FREE(ptr); +} + +_DMON_PRIVATE void* dmon__cf_realloc(void* ptr, CFIndex newsize, CFOptionFlags hints, void* info) +{ + _DMON_UNUSED(hints); + _DMON_UNUSED(info); + return DMON_REALLOC(ptr, (size_t)newsize); +} + +_DMON_PRIVATE void dmon__fsevent_process_events(void) +{ + for (int i = 0, c = stb_sb_count(_dmon.events); i < c; i++) { + dmon__fsevent_event* ev = &_dmon.events[i]; + if (ev->skip) { + continue; + } + + // remove redundant modify events on a single file + if (ev->event_flags & kFSEventStreamEventFlagItemModified) { + for (int j = i + 1; j < c; j++) { + dmon__fsevent_event* check_ev = &_dmon.events[j]; + if ((check_ev->event_flags & kFSEventStreamEventFlagItemModified) && + strcmp(ev->filepath, check_ev->filepath) == 0) { + ev->skip = true; + break; + } + } + } else if ((ev->event_flags & kFSEventStreamEventFlagItemRenamed) && !ev->move_valid) { + for (int j = i + 1; j < c; j++) { + dmon__fsevent_event* check_ev = &_dmon.events[j]; + if ((check_ev->event_flags & kFSEventStreamEventFlagItemRenamed) && + check_ev->event_id == (ev->event_id + 1)) { + ev->move_valid = check_ev->move_valid = true; + break; + } + } + + // in some environments like finder file explorer: + // when a file is deleted, it is moved to recycle bin + // so if the destination of the move is not valid, it's probably DELETE or CREATE + // decide CREATE if file exists + if (!ev->move_valid) { + ev->event_flags &= ~kFSEventStreamEventFlagItemRenamed; + + char abs_filepath[DMON_MAX_PATH]; + dmon__watch_state* watch = &_dmon.watches[ev->watch_id.id-1]; + dmon__strcpy(abs_filepath, sizeof(abs_filepath), watch->rootdir); + dmon__strcat(abs_filepath, sizeof(abs_filepath), ev->filepath); + + struct stat root_st; + if (stat(abs_filepath, &root_st) != 0) { + ev->event_flags |= kFSEventStreamEventFlagItemRemoved; + } else { + ev->event_flags |= kFSEventStreamEventFlagItemCreated; + } + } + } + } + + // trigger user callbacks + for (int i = 0, c = stb_sb_count(_dmon.events); i < c; i++) { + dmon__fsevent_event* ev = &_dmon.events[i]; + if (ev->skip) { + continue; + } + dmon__watch_state* watch = &_dmon.watches[ev->watch_id.id - 1]; + + if(watch == NULL || watch->watch_cb == NULL) { + continue; + } + + if (ev->event_flags & kFSEventStreamEventFlagItemCreated) { + watch->watch_cb(ev->watch_id, DMON_ACTION_CREATE, watch->rootdir_unmod, ev->filepath, NULL, + watch->user_data); + } else if (ev->event_flags & kFSEventStreamEventFlagItemModified) { + watch->watch_cb(ev->watch_id, DMON_ACTION_MODIFY, watch->rootdir_unmod, ev->filepath, NULL, + watch->user_data); + } else if (ev->event_flags & kFSEventStreamEventFlagItemRenamed) { + for (int j = i + 1; j < c; j++) { + dmon__fsevent_event* check_ev = &_dmon.events[j]; + if (check_ev->event_flags & kFSEventStreamEventFlagItemRenamed) { + watch->watch_cb(check_ev->watch_id, DMON_ACTION_MOVE, watch->rootdir_unmod, + check_ev->filepath, ev->filepath, watch->user_data); + break; + } + } + } else if (ev->event_flags & kFSEventStreamEventFlagItemRemoved) { + watch->watch_cb(ev->watch_id, DMON_ACTION_DELETE, watch->rootdir_unmod, ev->filepath, NULL, + watch->user_data); + } + } + + stb_sb_reset(_dmon.events); +} + +static void* dmon__thread(void* arg) +{ + _DMON_UNUSED(arg); + + struct timespec req = { (time_t)10 / 1000, (long)(10 * 1000000) }; + struct timespec rem = { 0, 0 }; + + _dmon.cf_loop_ref = CFRunLoopGetCurrent(); + dispatch_semaphore_signal(_dmon.thread_sem); + + while (!_dmon.quit) { + if (_dmon.modify_watches || pthread_mutex_trylock(&_dmon.mutex) != 0) { + nanosleep(&req, &rem); + continue; + } + + if (_dmon.num_watches == 0) { + nanosleep(&req, &rem); + pthread_mutex_unlock(&_dmon.mutex); + continue; + } + + for (int i = 0; i < _dmon.num_watches; i++) { + dmon__watch_state* watch = &_dmon.watches[i]; + if (!watch->init) { + DMON_ASSERT(watch->fsev_stream_ref); + FSEventStreamScheduleWithRunLoop(watch->fsev_stream_ref, _dmon.cf_loop_ref, + kCFRunLoopDefaultMode); + FSEventStreamStart(watch->fsev_stream_ref); + + watch->init = true; + } + } + + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.5, kCFRunLoopRunTimedOut); + dmon__fsevent_process_events(); + + pthread_mutex_unlock(&_dmon.mutex); + } + + CFRunLoopStop(_dmon.cf_loop_ref); + _dmon.cf_loop_ref = NULL; + return 0x0; +} + +_DMON_PRIVATE void dmon__unwatch(dmon__watch_state* watch) +{ + if (watch->fsev_stream_ref) { + FSEventStreamStop(watch->fsev_stream_ref); + FSEventStreamInvalidate(watch->fsev_stream_ref); + FSEventStreamRelease(watch->fsev_stream_ref); + watch->fsev_stream_ref = NULL; + } + + memset(watch, 0x0, sizeof(dmon__watch_state)); +} + +DMON_API_IMPL void dmon_init(void) +{ + DMON_ASSERT(!_dmon_init); + pthread_mutex_init(&_dmon.mutex, NULL); + + CFAllocatorContext cf_alloc_ctx = { 0 }; + cf_alloc_ctx.allocate = dmon__cf_malloc; + cf_alloc_ctx.deallocate = dmon__cf_free; + cf_alloc_ctx.reallocate = dmon__cf_realloc; + _dmon.cf_alloc_ref = CFAllocatorCreate(NULL, &cf_alloc_ctx); + + _dmon.thread_sem = dispatch_semaphore_create(0); + DMON_ASSERT(_dmon.thread_sem); + + int r = pthread_create(&_dmon.thread_handle, NULL, dmon__thread, NULL); + _DMON_UNUSED(r); + DMON_ASSERT(r == 0 && "pthread_create failed"); + + // wait for thread to initialize loop object + dispatch_semaphore_wait(_dmon.thread_sem, DISPATCH_TIME_FOREVER); + + _dmon_init = true; +} + +DMON_API_IMPL void dmon_deinit(void) +{ + DMON_ASSERT(_dmon_init); + _dmon.quit = true; + pthread_join(_dmon.thread_handle, NULL); + + dispatch_release(_dmon.thread_sem); + + for (int i = 0; i < _dmon.num_watches; i++) { + dmon__unwatch(&_dmon.watches[i]); + } + + pthread_mutex_destroy(&_dmon.mutex); + stb_sb_free(_dmon.events); + if (_dmon.cf_alloc_ref) { + CFRelease(_dmon.cf_alloc_ref); + } + + _dmon_init = false; +} + +_DMON_PRIVATE void dmon__fsevent_callback(ConstFSEventStreamRef stream_ref, void* user_data, + size_t num_events, void* event_paths, + const FSEventStreamEventFlags event_flags[], + const FSEventStreamEventId event_ids[]) +{ + _DMON_UNUSED(stream_ref); + + union dmon__cast_userdata _userdata; + _userdata.ptr = user_data; + dmon_watch_id watch_id = dmon__make_id(_userdata.id); + DMON_ASSERT(watch_id.id > 0); + dmon__watch_state* watch = &_dmon.watches[watch_id.id - 1]; + char abs_filepath[DMON_MAX_PATH]; + char abs_filepath_lower[DMON_MAX_PATH]; + + for (size_t i = 0; i < num_events; i++) { + const char* filepath = ((const char**)event_paths)[i]; + long flags = (long)event_flags[i]; + uint64_t event_id = (uint64_t)event_ids[i]; + dmon__fsevent_event ev; + memset(&ev, 0x0, sizeof(ev)); + + dmon__strcpy(abs_filepath, sizeof(abs_filepath), filepath); + dmon__unixpath(abs_filepath, sizeof(abs_filepath), abs_filepath); + + // normalize path, so it would be the same on both MacOS file-system types (case/nocase) + dmon__tolower(abs_filepath_lower, sizeof(abs_filepath), abs_filepath); + DMON_ASSERT(strstr(abs_filepath_lower, watch->rootdir) == abs_filepath_lower); + + // strip the root dir from the begining + dmon__strcpy(ev.filepath, sizeof(ev.filepath), abs_filepath + strlen(watch->rootdir)); + + ev.event_flags = flags; + ev.event_id = event_id; + ev.watch_id = watch_id; + stb_sb_push(_dmon.events, ev); + } +} + +DMON_API_IMPL dmon_watch_id dmon_watch(const char* rootdir, + void (*watch_cb)(dmon_watch_id watch_id, dmon_action action, + const char* dirname, const char* filename, + const char* oldname, void* user), + uint32_t flags, void* user_data) +{ + DMON_ASSERT(watch_cb); + DMON_ASSERT(rootdir && rootdir[0]); + + __sync_lock_test_and_set(&_dmon.modify_watches, 1); + pthread_mutex_lock(&_dmon.mutex); + + DMON_ASSERT(_dmon.num_watches < DMON_MAX_WATCHES); + + uint32_t id = ++_dmon.num_watches; + dmon__watch_state* watch = &_dmon.watches[id - 1]; + watch->id = dmon__make_id(id); + watch->watch_flags = flags; + watch->watch_cb = watch_cb; + watch->user_data = user_data; + + struct stat root_st; + if (stat(rootdir, &root_st) != 0 || !S_ISDIR(root_st.st_mode) || + (root_st.st_mode & S_IRUSR) != S_IRUSR) { + _DMON_LOG_ERRORF("Could not open/read directory: %s", rootdir); + pthread_mutex_unlock(&_dmon.mutex); + __sync_lock_test_and_set(&_dmon.modify_watches, 0); + return dmon__make_id(0); + } + + if (S_ISLNK(root_st.st_mode)) { + if (flags & DMON_WATCHFLAGS_FOLLOW_SYMLINKS) { + char linkpath[PATH_MAX]; + char* r = realpath(rootdir, linkpath); + _DMON_UNUSED(r); + DMON_ASSERT(r); + + dmon__strcpy(watch->rootdir, sizeof(watch->rootdir) - 1, linkpath); + } else { + _DMON_LOG_ERRORF("symlinks are unsupported: %s. use DMON_WATCHFLAGS_FOLLOW_SYMLINKS", rootdir); + pthread_mutex_unlock(&_dmon.mutex); + __sync_lock_test_and_set(&_dmon.modify_watches, 0); + return dmon__make_id(0); + } + } else { + char rootdir_abspath[DMON_MAX_PATH]; + if (realpath(rootdir, rootdir_abspath) != NULL) { + dmon__strcpy(watch->rootdir, sizeof(watch->rootdir) - 1, rootdir_abspath); + } else { + dmon__strcpy(watch->rootdir, sizeof(watch->rootdir) - 1, rootdir); + } + } + + dmon__unixpath(watch->rootdir, sizeof(watch->rootdir), watch->rootdir); + + // add trailing slash + int rootdir_len = (int)strlen(watch->rootdir); + if (watch->rootdir[rootdir_len - 1] != '/') { + watch->rootdir[rootdir_len] = '/'; + watch->rootdir[rootdir_len + 1] = '\0'; + } + + dmon__strcpy(watch->rootdir_unmod, sizeof(watch->rootdir_unmod), watch->rootdir); + dmon__tolower(watch->rootdir, sizeof(watch->rootdir), watch->rootdir); + + // create FS objects + CFStringRef cf_dir = CFStringCreateWithCString(NULL, watch->rootdir_unmod, kCFStringEncodingUTF8); + CFArrayRef cf_dirarr = CFArrayCreate(NULL, (const void**)&cf_dir, 1, NULL); + + FSEventStreamContext ctx; + union dmon__cast_userdata userdata; + userdata.id = id; + ctx.version = 0; + ctx.info = userdata.ptr; + ctx.retain = NULL; + ctx.release = NULL; + ctx.copyDescription = NULL; + watch->fsev_stream_ref = FSEventStreamCreate(_dmon.cf_alloc_ref, dmon__fsevent_callback, &ctx, + cf_dirarr, kFSEventStreamEventIdSinceNow, 0.25, + kFSEventStreamCreateFlagFileEvents); + + + CFRelease(cf_dirarr); + CFRelease(cf_dir); + + pthread_mutex_unlock(&_dmon.mutex); + __sync_lock_test_and_set(&_dmon.modify_watches, 0); + return dmon__make_id(id); +} + +DMON_API_IMPL void dmon_unwatch(dmon_watch_id id) +{ + DMON_ASSERT(id.id > 0); + + __sync_lock_test_and_set(&_dmon.modify_watches, 1); + pthread_mutex_lock(&_dmon.mutex); + + int index = id.id - 1; + DMON_ASSERT(index < _dmon.num_watches); + + dmon__unwatch(&_dmon.watches[index]); + if (index != _dmon.num_watches - 1) { + dmon__swap(_dmon.watches[index], _dmon.watches[_dmon.num_watches - 1], dmon__watch_state); + } + --_dmon.num_watches; + + pthread_mutex_unlock(&_dmon.mutex); + __sync_lock_test_and_set(&_dmon.modify_watches, 0); +} + +#endif + +#endif // DMON_IMPL +#endif // __DMON_H__ diff --git a/lib/dmon/dmon_extra.h b/lib/dmon/dmon_extra.h new file mode 100644 index 00000000..4b321034 --- /dev/null +++ b/lib/dmon/dmon_extra.h @@ -0,0 +1,162 @@ +#ifndef __DMON_EXTRA_H__ +#define __DMON_EXTRA_H__ + +// +// Copyright 2021 Sepehr Taghdisian (septag@github). All rights reserved. +// License: https://github.com/septag/dmon#license-bsd-2-clause +// +// Extra header functionality for dmon.h for the backend based on inotify +// +// Add/Remove directory functions: +// dmon_watch_add: Adds a sub-directory to already valid watch_id. sub-directories are assumed to be relative to watch root_dir +// dmon_watch_add: Removes a sub-directory from already valid watch_id. sub-directories are assumed to be relative to watch root_dir +// Reason: The inotify backend does not work well when watching in recursive mode a root directory of a large tree, it may take +// quite a while until all inotify watches are established, and events will not be received in this time. Also, since one +// inotify watch will be established per subdirectory, it is possible that the maximum amount of inotify watches per user +// will be reached. The default maximum is 8192. +// When using inotify backend users may turn off the DMON_WATCHFLAGS_RECURSIVE flag and add/remove selectively the +// sub-directories to be watched based on application-specific logic about which sub-directory actually needs to be watched. +// The function dmon_watch_add and dmon_watch_rm are used to this purpose. +// + +#ifndef __DMON_H__ +#error "Include 'dmon.h' before including this file" +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +DMON_API_DECL bool dmon_watch_add(dmon_watch_id id, const char* subdir); +DMON_API_DECL bool dmon_watch_rm(dmon_watch_id id, const char* watchdir); + +#ifdef __cplusplus +} +#endif + +#ifdef DMON_IMPL +#if DMON_OS_LINUX +DMON_API_IMPL bool dmon_watch_add(dmon_watch_id id, const char* watchdir) +{ + DMON_ASSERT(id.id > 0 && id.id <= DMON_MAX_WATCHES); + + bool skip_lock = pthread_self() == _dmon.thread_handle; + + if (!skip_lock) + pthread_mutex_lock(&_dmon.mutex); + + dmon__watch_state* watch = &_dmon.watches[id.id - 1]; + + // check if the directory exists + // if watchdir contains absolute/root-included path, try to strip the rootdir from it + // else, we assume that watchdir is correct, so save it as it is + struct stat st; + dmon__watch_subdir subdir; + if (stat(watchdir, &st) == 0 && (st.st_mode & S_IFDIR)) { + dmon__strcpy(subdir.rootdir, sizeof(subdir.rootdir), watchdir); + if (strstr(subdir.rootdir, watch->rootdir) == subdir.rootdir) { + dmon__strcpy(subdir.rootdir, sizeof(subdir.rootdir), watchdir + strlen(watch->rootdir)); + } + } else { + char fullpath[DMON_MAX_PATH]; + dmon__strcpy(fullpath, sizeof(fullpath), watch->rootdir); + dmon__strcat(fullpath, sizeof(fullpath), watchdir); + if (stat(fullpath, &st) != 0 || (st.st_mode & S_IFDIR) == 0) { + _DMON_LOG_ERRORF("Watch directory '%s' is not valid", watchdir); + if (!skip_lock) + pthread_mutex_unlock(&_dmon.mutex); + return false; + } + dmon__strcpy(subdir.rootdir, sizeof(subdir.rootdir), watchdir); + } + + int dirlen = (int)strlen(subdir.rootdir); + if (subdir.rootdir[dirlen - 1] != '/') { + subdir.rootdir[dirlen] = '/'; + subdir.rootdir[dirlen + 1] = '\0'; + } + + // check that the directory is not already added + for (int i = 0, c = stb_sb_count(watch->subdirs); i < c; i++) { + if (strcmp(subdir.rootdir, watch->subdirs[i].rootdir) == 0) { + _DMON_LOG_ERRORF("Error watching directory '%s', because it is already added.", watchdir); + if (!skip_lock) + pthread_mutex_unlock(&_dmon.mutex); + return false; + } + } + + const uint32_t inotify_mask = IN_MOVED_TO | IN_CREATE | IN_MOVED_FROM | IN_DELETE | IN_MODIFY; + char fullpath[DMON_MAX_PATH]; + dmon__strcpy(fullpath, sizeof(fullpath), watch->rootdir); + dmon__strcat(fullpath, sizeof(fullpath), subdir.rootdir); + int wd = inotify_add_watch(watch->fd, fullpath, inotify_mask); + if (wd == -1) { + _DMON_LOG_ERRORF("Error watching directory '%s'. (inotify_add_watch:err=%d)", watchdir, errno); + if (!skip_lock) + pthread_mutex_unlock(&_dmon.mutex); + return false; + } + + stb_sb_push(watch->subdirs, subdir); + stb_sb_push(watch->wds, wd); + + if (!skip_lock) + pthread_mutex_unlock(&_dmon.mutex); + + return true; +} + +DMON_API_IMPL bool dmon_watch_rm(dmon_watch_id id, const char* watchdir) +{ + DMON_ASSERT(id.id > 0 && id.id <= DMON_MAX_WATCHES); + + bool skip_lock = pthread_self() == _dmon.thread_handle; + + if (!skip_lock) + pthread_mutex_lock(&_dmon.mutex); + + dmon__watch_state* watch = &_dmon.watches[id.id - 1]; + + char subdir[DMON_MAX_PATH]; + dmon__strcpy(subdir, sizeof(subdir), watchdir); + if (strstr(subdir, watch->rootdir) == subdir) { + dmon__strcpy(subdir, sizeof(subdir), watchdir + strlen(watch->rootdir)); + } + + int dirlen = (int)strlen(subdir); + if (subdir[dirlen - 1] != '/') { + subdir[dirlen] = '/'; + subdir[dirlen + 1] = '\0'; + } + + int i, c = stb_sb_count(watch->subdirs); + for (i = 0; i < c; i++) { + if (strcmp(watch->subdirs[i].rootdir, subdir) == 0) { + break; + } + } + if (i >= c) { + _DMON_LOG_ERRORF("Watch directory '%s' is not valid", watchdir); + if (!skip_lock) + pthread_mutex_unlock(&_dmon.mutex); + return false; + } + inotify_rm_watch(watch->fd, watch->wds[i]); + + /* Remove entry from subdirs and wds by swapping position with the last entry */ + watch->subdirs[i] = stb_sb_last(watch->subdirs); + stb_sb_pop(watch->subdirs); + + watch->wds[i] = stb_sb_last(watch->wds); + stb_sb_pop(watch->wds); + + if (!skip_lock) + pthread_mutex_unlock(&_dmon.mutex); + return true; +} +#endif // DMON_OS_LINUX +#endif // DMON_IMPL + +#endif // __DMON_EXTRA_H__ + diff --git a/lib/dmon/meson.build b/lib/dmon/meson.build new file mode 100644 index 00000000..83edd1c9 --- /dev/null +++ b/lib/dmon/meson.build @@ -0,0 +1 @@ +lite_includes += include_directories('.') diff --git a/licenses/licenses.md b/licenses/licenses.md index 8005c4a7..928d88d9 100644 --- a/licenses/licenses.md +++ b/licenses/licenses.md @@ -22,6 +22,33 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## septag/dmon + +Copyright 2019 Sepehr Taghdisian. All rights reserved. + +https://github.com/septag/dmon + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY COPYRIGHT HOLDER "AS IS" AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO +EVENT SHALL COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ## Fira Sans Digitized data copyright (c) 2012-2015, The Mozilla Foundation and Telefonica S.A. diff --git a/meson.build b/meson.build index 53883266..8456fe60 100644 --- a/meson.build +++ b/meson.build @@ -1,6 +1,6 @@ project('lite-xl', ['c'], - version : '2.0.2', + version : '2.0.3', license : 'MIT', meson_version : '>= 0.54', default_options : ['c_std=gnu11'] @@ -23,6 +23,7 @@ endif cc = meson.get_compiler('c') +lite_includes = [] lite_cargs = [] # On macos we need to use the SDL renderer to support retina displays if get_option('renderer') or host_machine.system() == 'darwin' @@ -45,6 +46,7 @@ endif if not get_option('source-only') libm = cc.find_library('m', required : false) libdl = cc.find_library('dl', required : false) + threads_dep = dependency('threads') lua_dep = dependency('lua5.2', fallback: ['lua', 'lua_dep'], default_options: ['shared=false', 'use_readline=false', 'app=false'] ) @@ -58,7 +60,7 @@ if not get_option('source-only') ] ) - lite_deps = [lua_dep, sdl_dep, reproc_dep, pcre2_dep, libm, libdl, freetype_dep] + lite_deps = [lua_dep, sdl_dep, reproc_dep, pcre2_dep, libm, libdl, freetype_dep, threads_dep] if host_machine.system() == 'windows' # Note that we need to explicitly add the windows socket DLL because @@ -118,10 +120,8 @@ configure_file( install_dir : lite_datadir / 'core', ) -#=============================================================================== -# Targets -#=============================================================================== if not get_option('source-only') + subdir('lib/dmon') subdir('src') subdir('scripts') endif diff --git a/resources/notes-dmon-integration.md b/resources/notes-dmon-integration.md new file mode 100644 index 00000000..5179df40 --- /dev/null +++ b/resources/notes-dmon-integration.md @@ -0,0 +1,54 @@ + +`core.set_project_dir`: + Reset project directories and set its directory. + It chdir into the directory, empty the `core.project_directories` and add + the given directory. + `core.add_project_directory`: + Add a new top-level directory to the project. + Also called from modules and commands outside core.init. + local function `scan_project_folder`: + Scan all files for a given top-level project directory. + Can emit a warning about file limit. + Called only from within core.init module. + +`core.scan_project_subdir`: (before was named `core.scan_project_folder`) + scan a single folder, without recursion. Used when too many files. + +Local function `scan_project_folder`: + Populate the project folder top directory. Done only once when the directory + is added to the project. + +`core.add_project_directory`: + Add a new top-level folder to the project. + +`core.set_project_dir`: + Set the initial project directory. + +`core.dir_rescan_add_job`: + Add a job to rescan after an elapsed time a project's subdirectory to fix for any + changes. + +Local function `rescan_project_subdir`: + Rescan a project's subdirectory, compare to the current version and patch the list if + a difference is found. + + +`core.project_scan_thread`: + Should disappear now that we use dmon. + + +`core.project_scan_topdir`: + New function to scan a top level project folder. + + +`config.project_scan_rate`: +`core.project_scan_thread_id`: +`core.reschedule_project_scan`: +`core.project_files_limit`: + A eliminer. + +`core.get_project_files`: + To be fixed. Use `find_project_files_co` for a single directory + +In TreeView remove usage of self.last to detect new scan that changed the files list. + diff --git a/scripts/README.md b/scripts/README.md index bd41f7f1..478e461f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -23,6 +23,7 @@ Various scripts and configurations used to configure, build, and package Lite XL Preferably not to be used in user systems. - **fontello-config.json**: Used by the icons generator. - **generate_header.sh**: Generates a header file for native plugin API +- **keymap-generator**: Generates a JSON file containing the keymap [1]: https://github.com/LinusU/node-appdmg [2]: https://docs.appimage.org/ diff --git a/scripts/keymap-generator/dkjson.lua b/scripts/keymap-generator/dkjson.lua new file mode 100644 index 00000000..fa50b9fa --- /dev/null +++ b/scripts/keymap-generator/dkjson.lua @@ -0,0 +1,714 @@ +-- Module options: +local always_try_using_lpeg = true +local register_global_module_table = false +local global_module_name = 'json' + +--[==[ + +David Kolf's JSON module for Lua 5.1/5.2 + +Version 2.5 + + +For the documentation see the corresponding readme.txt or visit +. + +You can contact the author by sending an e-mail to 'david' at the +domain 'dkolf.de'. + + +Copyright (C) 2010-2013 David Heiko Kolf + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--]==] + +-- global dependencies: +local pairs, type, tostring, tonumber, getmetatable, setmetatable, rawset = + pairs, type, tostring, tonumber, getmetatable, setmetatable, rawset +local error, require, pcall, select = error, require, pcall, select +local floor, huge = math.floor, math.huge +local strrep, gsub, strsub, strbyte, strchar, strfind, strlen, strformat = + string.rep, string.gsub, string.sub, string.byte, string.char, + string.find, string.len, string.format +local strmatch = string.match +local concat = table.concat + +local json = { version = "dkjson 2.5" } + +if register_global_module_table then + _G[global_module_name] = json +end + +local _ENV = nil -- blocking globals in Lua 5.2 + +pcall (function() + -- Enable access to blocked metatables. + -- Don't worry, this module doesn't change anything in them. + local debmeta = require "debug".getmetatable + if debmeta then getmetatable = debmeta end +end) + +json.null = setmetatable ({}, { + __tojson = function () return "null" end +}) + +local function isarray (tbl) + local max, n, arraylen = 0, 0, 0 + for k,v in pairs (tbl) do + if k == 'n' and type(v) == 'number' then + arraylen = v + if v > max then + max = v + end + else + if type(k) ~= 'number' or k < 1 or floor(k) ~= k then + return false + end + if k > max then + max = k + end + n = n + 1 + end + end + if max > 10 and max > arraylen and max > n * 2 then + return false -- don't create an array with too many holes + end + return true, max +end + +local escapecodes = { + ["\""] = "\\\"", ["\\"] = "\\\\", ["\b"] = "\\b", ["\f"] = "\\f", + ["\n"] = "\\n", ["\r"] = "\\r", ["\t"] = "\\t" +} + +local function escapeutf8 (uchar) + local value = escapecodes[uchar] + if value then + return value + end + local a, b, c, d = strbyte (uchar, 1, 4) + a, b, c, d = a or 0, b or 0, c or 0, d or 0 + if a <= 0x7f then + value = a + elseif 0xc0 <= a and a <= 0xdf and b >= 0x80 then + value = (a - 0xc0) * 0x40 + b - 0x80 + elseif 0xe0 <= a and a <= 0xef and b >= 0x80 and c >= 0x80 then + value = ((a - 0xe0) * 0x40 + b - 0x80) * 0x40 + c - 0x80 + elseif 0xf0 <= a and a <= 0xf7 and b >= 0x80 and c >= 0x80 and d >= 0x80 then + value = (((a - 0xf0) * 0x40 + b - 0x80) * 0x40 + c - 0x80) * 0x40 + d - 0x80 + else + return "" + end + if value <= 0xffff then + return strformat ("\\u%.4x", value) + elseif value <= 0x10ffff then + -- encode as UTF-16 surrogate pair + value = value - 0x10000 + local highsur, lowsur = 0xD800 + floor (value/0x400), 0xDC00 + (value % 0x400) + return strformat ("\\u%.4x\\u%.4x", highsur, lowsur) + else + return "" + end +end + +local function fsub (str, pattern, repl) + -- gsub always builds a new string in a buffer, even when no match + -- exists. First using find should be more efficient when most strings + -- don't contain the pattern. + if strfind (str, pattern) then + return gsub (str, pattern, repl) + else + return str + end +end + +local function quotestring (value) + -- based on the regexp "escapable" in https://github.com/douglascrockford/JSON-js + value = fsub (value, "[%z\1-\31\"\\\127]", escapeutf8) + if strfind (value, "[\194\216\220\225\226\239]") then + value = fsub (value, "\194[\128-\159\173]", escapeutf8) + value = fsub (value, "\216[\128-\132]", escapeutf8) + value = fsub (value, "\220\143", escapeutf8) + value = fsub (value, "\225\158[\180\181]", escapeutf8) + value = fsub (value, "\226\128[\140-\143\168-\175]", escapeutf8) + value = fsub (value, "\226\129[\160-\175]", escapeutf8) + value = fsub (value, "\239\187\191", escapeutf8) + value = fsub (value, "\239\191[\176-\191]", escapeutf8) + end + return "\"" .. value .. "\"" +end +json.quotestring = quotestring + +local function replace(str, o, n) + local i, j = strfind (str, o, 1, true) + if i then + return strsub(str, 1, i-1) .. n .. strsub(str, j+1, -1) + else + return str + end +end + +-- locale independent num2str and str2num functions +local decpoint, numfilter + +local function updatedecpoint () + decpoint = strmatch(tostring(0.5), "([^05+])") + -- build a filter that can be used to remove group separators + numfilter = "[^0-9%-%+eE" .. gsub(decpoint, "[%^%$%(%)%%%.%[%]%*%+%-%?]", "%%%0") .. "]+" +end + +updatedecpoint() + +local function num2str (num) + return replace(fsub(tostring(num), numfilter, ""), decpoint, ".") +end + +local function str2num (str) + local num = tonumber(replace(str, ".", decpoint)) + if not num then + updatedecpoint() + num = tonumber(replace(str, ".", decpoint)) + end + return num +end + +local function addnewline2 (level, buffer, buflen) + buffer[buflen+1] = "\n" + buffer[buflen+2] = strrep (" ", level) + buflen = buflen + 2 + return buflen +end + +function json.addnewline (state) + if state.indent then + state.bufferlen = addnewline2 (state.level or 0, + state.buffer, state.bufferlen or #(state.buffer)) + end +end + +local encode2 -- forward declaration + +local function addpair (key, value, prev, indent, level, buffer, buflen, tables, globalorder, state) + local kt = type (key) + if kt ~= 'string' and kt ~= 'number' then + return nil, "type '" .. kt .. "' is not supported as a key by JSON." + end + if prev then + buflen = buflen + 1 + buffer[buflen] = "," + end + if indent then + buflen = addnewline2 (level, buffer, buflen) + end + buffer[buflen+1] = quotestring (key) + buffer[buflen+2] = ":" + return encode2 (value, indent, level, buffer, buflen + 2, tables, globalorder, state) +end + +local function appendcustom(res, buffer, state) + local buflen = state.bufferlen + if type (res) == 'string' then + buflen = buflen + 1 + buffer[buflen] = res + end + return buflen +end + +local function exception(reason, value, state, buffer, buflen, defaultmessage) + defaultmessage = defaultmessage or reason + local handler = state.exception + if not handler then + return nil, defaultmessage + else + state.bufferlen = buflen + local ret, msg = handler (reason, value, state, defaultmessage) + if not ret then return nil, msg or defaultmessage end + return appendcustom(ret, buffer, state) + end +end + +function json.encodeexception(reason, value, state, defaultmessage) + return quotestring("<" .. defaultmessage .. ">") +end + +encode2 = function (value, indent, level, buffer, buflen, tables, globalorder, state) + local valtype = type (value) + local valmeta = getmetatable (value) + valmeta = type (valmeta) == 'table' and valmeta -- only tables + local valtojson = valmeta and valmeta.__tojson + if valtojson then + if tables[value] then + return exception('reference cycle', value, state, buffer, buflen) + end + tables[value] = true + state.bufferlen = buflen + local ret, msg = valtojson (value, state) + if not ret then return exception('custom encoder failed', value, state, buffer, buflen, msg) end + tables[value] = nil + buflen = appendcustom(ret, buffer, state) + elseif value == nil then + buflen = buflen + 1 + buffer[buflen] = "null" + elseif valtype == 'number' then + local s + if value ~= value or value >= huge or -value >= huge then + -- This is the behaviour of the original JSON implementation. + s = "null" + else + s = num2str (value) + end + buflen = buflen + 1 + buffer[buflen] = s + elseif valtype == 'boolean' then + buflen = buflen + 1 + buffer[buflen] = value and "true" or "false" + elseif valtype == 'string' then + buflen = buflen + 1 + buffer[buflen] = quotestring (value) + elseif valtype == 'table' then + if tables[value] then + return exception('reference cycle', value, state, buffer, buflen) + end + tables[value] = true + level = level + 1 + local isa, n = isarray (value) + if n == 0 and valmeta and valmeta.__jsontype == 'object' then + isa = false + end + local msg + if isa then -- JSON array + buflen = buflen + 1 + buffer[buflen] = "[" + for i = 1, n do + buflen, msg = encode2 (value[i], indent, level, buffer, buflen, tables, globalorder, state) + if not buflen then return nil, msg end + if i < n then + buflen = buflen + 1 + buffer[buflen] = "," + end + end + buflen = buflen + 1 + buffer[buflen] = "]" + else -- JSON object + local prev = false + buflen = buflen + 1 + buffer[buflen] = "{" + local order = valmeta and valmeta.__jsonorder or globalorder + if order then + local used = {} + n = #order + for i = 1, n do + local k = order[i] + local v = value[k] + if v then + used[k] = true + buflen, msg = addpair (k, v, prev, indent, level, buffer, buflen, tables, globalorder, state) + prev = true -- add a seperator before the next element + end + end + for k,v in pairs (value) do + if not used[k] then + buflen, msg = addpair (k, v, prev, indent, level, buffer, buflen, tables, globalorder, state) + if not buflen then return nil, msg end + prev = true -- add a seperator before the next element + end + end + else -- unordered + for k,v in pairs (value) do + buflen, msg = addpair (k, v, prev, indent, level, buffer, buflen, tables, globalorder, state) + if not buflen then return nil, msg end + prev = true -- add a seperator before the next element + end + end + if indent then + buflen = addnewline2 (level - 1, buffer, buflen) + end + buflen = buflen + 1 + buffer[buflen] = "}" + end + tables[value] = nil + else + return exception ('unsupported type', value, state, buffer, buflen, + "type '" .. valtype .. "' is not supported by JSON.") + end + return buflen +end + +function json.encode (value, state) + state = state or {} + local oldbuffer = state.buffer + local buffer = oldbuffer or {} + state.buffer = buffer + updatedecpoint() + local ret, msg = encode2 (value, state.indent, state.level or 0, + buffer, state.bufferlen or 0, state.tables or {}, state.keyorder, state) + if not ret then + error (msg, 2) + elseif oldbuffer == buffer then + state.bufferlen = ret + return true + else + state.bufferlen = nil + state.buffer = nil + return concat (buffer) + end +end + +local function loc (str, where) + local line, pos, linepos = 1, 1, 0 + while true do + pos = strfind (str, "\n", pos, true) + if pos and pos < where then + line = line + 1 + linepos = pos + pos = pos + 1 + else + break + end + end + return "line " .. line .. ", column " .. (where - linepos) +end + +local function unterminated (str, what, where) + return nil, strlen (str) + 1, "unterminated " .. what .. " at " .. loc (str, where) +end + +local function scanwhite (str, pos) + while true do + pos = strfind (str, "%S", pos) + if not pos then return nil end + local sub2 = strsub (str, pos, pos + 1) + if sub2 == "\239\187" and strsub (str, pos + 2, pos + 2) == "\191" then + -- UTF-8 Byte Order Mark + pos = pos + 3 + elseif sub2 == "//" then + pos = strfind (str, "[\n\r]", pos + 2) + if not pos then return nil end + elseif sub2 == "/*" then + pos = strfind (str, "*/", pos + 2) + if not pos then return nil end + pos = pos + 2 + else + return pos + end + end +end + +local escapechars = { + ["\""] = "\"", ["\\"] = "\\", ["/"] = "/", ["b"] = "\b", ["f"] = "\f", + ["n"] = "\n", ["r"] = "\r", ["t"] = "\t" +} + +local function unichar (value) + if value < 0 then + return nil + elseif value <= 0x007f then + return strchar (value) + elseif value <= 0x07ff then + return strchar (0xc0 + floor(value/0x40), + 0x80 + (floor(value) % 0x40)) + elseif value <= 0xffff then + return strchar (0xe0 + floor(value/0x1000), + 0x80 + (floor(value/0x40) % 0x40), + 0x80 + (floor(value) % 0x40)) + elseif value <= 0x10ffff then + return strchar (0xf0 + floor(value/0x40000), + 0x80 + (floor(value/0x1000) % 0x40), + 0x80 + (floor(value/0x40) % 0x40), + 0x80 + (floor(value) % 0x40)) + else + return nil + end +end + +local function scanstring (str, pos) + local lastpos = pos + 1 + local buffer, n = {}, 0 + while true do + local nextpos = strfind (str, "[\"\\]", lastpos) + if not nextpos then + return unterminated (str, "string", pos) + end + if nextpos > lastpos then + n = n + 1 + buffer[n] = strsub (str, lastpos, nextpos - 1) + end + if strsub (str, nextpos, nextpos) == "\"" then + lastpos = nextpos + 1 + break + else + local escchar = strsub (str, nextpos + 1, nextpos + 1) + local value + if escchar == "u" then + value = tonumber (strsub (str, nextpos + 2, nextpos + 5), 16) + if value then + local value2 + if 0xD800 <= value and value <= 0xDBff then + -- we have the high surrogate of UTF-16. Check if there is a + -- low surrogate escaped nearby to combine them. + if strsub (str, nextpos + 6, nextpos + 7) == "\\u" then + value2 = tonumber (strsub (str, nextpos + 8, nextpos + 11), 16) + if value2 and 0xDC00 <= value2 and value2 <= 0xDFFF then + value = (value - 0xD800) * 0x400 + (value2 - 0xDC00) + 0x10000 + else + value2 = nil -- in case it was out of range for a low surrogate + end + end + end + value = value and unichar (value) + if value then + if value2 then + lastpos = nextpos + 12 + else + lastpos = nextpos + 6 + end + end + end + end + if not value then + value = escapechars[escchar] or escchar + lastpos = nextpos + 2 + end + n = n + 1 + buffer[n] = value + end + end + if n == 1 then + return buffer[1], lastpos + elseif n > 1 then + return concat (buffer), lastpos + else + return "", lastpos + end +end + +local scanvalue -- forward declaration + +local function scantable (what, closechar, str, startpos, nullval, objectmeta, arraymeta) + local len = strlen (str) + local tbl, n = {}, 0 + local pos = startpos + 1 + if what == 'object' then + setmetatable (tbl, objectmeta) + else + setmetatable (tbl, arraymeta) + end + while true do + pos = scanwhite (str, pos) + if not pos then return unterminated (str, what, startpos) end + local char = strsub (str, pos, pos) + if char == closechar then + return tbl, pos + 1 + end + local val1, err + val1, pos, err = scanvalue (str, pos, nullval, objectmeta, arraymeta) + if err then return nil, pos, err end + pos = scanwhite (str, pos) + if not pos then return unterminated (str, what, startpos) end + char = strsub (str, pos, pos) + if char == ":" then + if val1 == nil then + return nil, pos, "cannot use nil as table index (at " .. loc (str, pos) .. ")" + end + pos = scanwhite (str, pos + 1) + if not pos then return unterminated (str, what, startpos) end + local val2 + val2, pos, err = scanvalue (str, pos, nullval, objectmeta, arraymeta) + if err then return nil, pos, err end + tbl[val1] = val2 + pos = scanwhite (str, pos) + if not pos then return unterminated (str, what, startpos) end + char = strsub (str, pos, pos) + else + n = n + 1 + tbl[n] = val1 + end + if char == "," then + pos = pos + 1 + end + end +end + +scanvalue = function (str, pos, nullval, objectmeta, arraymeta) + pos = pos or 1 + pos = scanwhite (str, pos) + if not pos then + return nil, strlen (str) + 1, "no valid JSON value (reached the end)" + end + local char = strsub (str, pos, pos) + if char == "{" then + return scantable ('object', "}", str, pos, nullval, objectmeta, arraymeta) + elseif char == "[" then + return scantable ('array', "]", str, pos, nullval, objectmeta, arraymeta) + elseif char == "\"" then + return scanstring (str, pos) + else + local pstart, pend = strfind (str, "^%-?[%d%.]+[eE]?[%+%-]?%d*", pos) + if pstart then + local number = str2num (strsub (str, pstart, pend)) + if number then + return number, pend + 1 + end + end + pstart, pend = strfind (str, "^%a%w*", pos) + if pstart then + local name = strsub (str, pstart, pend) + if name == "true" then + return true, pend + 1 + elseif name == "false" then + return false, pend + 1 + elseif name == "null" then + return nullval, pend + 1 + end + end + return nil, pos, "no valid JSON value at " .. loc (str, pos) + end +end + +local function optionalmetatables(...) + if select("#", ...) > 0 then + return ... + else + return {__jsontype = 'object'}, {__jsontype = 'array'} + end +end + +function json.decode (str, pos, nullval, ...) + local objectmeta, arraymeta = optionalmetatables(...) + return scanvalue (str, pos, nullval, objectmeta, arraymeta) +end + +function json.use_lpeg () + local g = require ("lpeg") + + if g.version() == "0.11" then + error "due to a bug in LPeg 0.11, it cannot be used for JSON matching" + end + + local pegmatch = g.match + local P, S, R = g.P, g.S, g.R + + local function ErrorCall (str, pos, msg, state) + if not state.msg then + state.msg = msg .. " at " .. loc (str, pos) + state.pos = pos + end + return false + end + + local function Err (msg) + return g.Cmt (g.Cc (msg) * g.Carg (2), ErrorCall) + end + + local SingleLineComment = P"//" * (1 - S"\n\r")^0 + local MultiLineComment = P"/*" * (1 - P"*/")^0 * P"*/" + local Space = (S" \n\r\t" + P"\239\187\191" + SingleLineComment + MultiLineComment)^0 + + local PlainChar = 1 - S"\"\\\n\r" + local EscapeSequence = (P"\\" * g.C (S"\"\\/bfnrt" + Err "unsupported escape sequence")) / escapechars + local HexDigit = R("09", "af", "AF") + local function UTF16Surrogate (match, pos, high, low) + high, low = tonumber (high, 16), tonumber (low, 16) + if 0xD800 <= high and high <= 0xDBff and 0xDC00 <= low and low <= 0xDFFF then + return true, unichar ((high - 0xD800) * 0x400 + (low - 0xDC00) + 0x10000) + else + return false + end + end + local function UTF16BMP (hex) + return unichar (tonumber (hex, 16)) + end + local U16Sequence = (P"\\u" * g.C (HexDigit * HexDigit * HexDigit * HexDigit)) + local UnicodeEscape = g.Cmt (U16Sequence * U16Sequence, UTF16Surrogate) + U16Sequence/UTF16BMP + local Char = UnicodeEscape + EscapeSequence + PlainChar + local String = P"\"" * g.Cs (Char ^ 0) * (P"\"" + Err "unterminated string") + local Integer = P"-"^(-1) * (P"0" + (R"19" * R"09"^0)) + local Fractal = P"." * R"09"^0 + local Exponent = (S"eE") * (S"+-")^(-1) * R"09"^1 + local Number = (Integer * Fractal^(-1) * Exponent^(-1))/str2num + local Constant = P"true" * g.Cc (true) + P"false" * g.Cc (false) + P"null" * g.Carg (1) + local SimpleValue = Number + String + Constant + local ArrayContent, ObjectContent + + -- The functions parsearray and parseobject parse only a single value/pair + -- at a time and store them directly to avoid hitting the LPeg limits. + local function parsearray (str, pos, nullval, state) + local obj, cont + local npos + local t, nt = {}, 0 + repeat + obj, cont, npos = pegmatch (ArrayContent, str, pos, nullval, state) + if not npos then break end + pos = npos + nt = nt + 1 + t[nt] = obj + until cont == 'last' + return pos, setmetatable (t, state.arraymeta) + end + + local function parseobject (str, pos, nullval, state) + local obj, key, cont + local npos + local t = {} + repeat + key, obj, cont, npos = pegmatch (ObjectContent, str, pos, nullval, state) + if not npos then break end + pos = npos + t[key] = obj + until cont == 'last' + return pos, setmetatable (t, state.objectmeta) + end + + local Array = P"[" * g.Cmt (g.Carg(1) * g.Carg(2), parsearray) * Space * (P"]" + Err "']' expected") + local Object = P"{" * g.Cmt (g.Carg(1) * g.Carg(2), parseobject) * Space * (P"}" + Err "'}' expected") + local Value = Space * (Array + Object + SimpleValue) + local ExpectedValue = Value + Space * Err "value expected" + ArrayContent = Value * Space * (P"," * g.Cc'cont' + g.Cc'last') * g.Cp() + local Pair = g.Cg (Space * String * Space * (P":" + Err "colon expected") * ExpectedValue) + ObjectContent = Pair * Space * (P"," * g.Cc'cont' + g.Cc'last') * g.Cp() + local DecodeValue = ExpectedValue * g.Cp () + + function json.decode (str, pos, nullval, ...) + local state = {} + state.objectmeta, state.arraymeta = optionalmetatables(...) + local obj, retpos = pegmatch (DecodeValue, str, pos, nullval, state) + if state.msg then + return nil, state.pos, state.msg + else + return obj, retpos + end + end + + -- use this function only once: + json.use_lpeg = function () return json end + + json.using_lpeg = true + + return json -- so you can get the module using json = require "dkjson".use_lpeg() +end + +if always_try_using_lpeg then + pcall (json.use_lpeg) +end + +return json + diff --git a/scripts/keymap-generator/keymap-generator.lua b/scripts/keymap-generator/keymap-generator.lua new file mode 100644 index 00000000..235d48c2 --- /dev/null +++ b/scripts/keymap-generator/keymap-generator.lua @@ -0,0 +1,75 @@ +#!/usr/bin/env lua +local dkjson = require "dkjson" + + +local function load_keymap(target, target_map, macos) + _G.MACOS = macos + package.loaded["core.keymap"] = nil + local keymap = require "core.keymap" + + if target then + keymap.map = {} + require(target) + end + + target_map = target_map or {} + -- keymap.reverse_map does not do this? + for key, actions in pairs(keymap.map) do + for _, action in ipairs(actions) do + target_map[action] = target_map[action] or {} + table.insert(target_map[action], key) + end + end + + return target_map +end + + +local function normalize(map) + local result = {} + for action, keys in pairs(map) do + local uniq = {} + local r = { combination = {}, action = action } + for _, v in ipairs(keys) do + if not uniq[v] then + uniq[v] = true + r.combination[#r.combination+1] = v + end + end + result[#result+1] = r + end + table.sort(result, function(a, b) return a.action < b.action end) + return result +end + + +local function process_module(mod, filename) + local map = {} + load_keymap(mod, map) + load_keymap(mod, map, true) + map = normalize(map) + local f = assert(io.open(filename, "wb")) + f:write(dkjson.encode(map, { indent = true })) + f:close() +end + + +print("Warning: this is not guaranteed to work outside lite-xl's own keymap. Proceed with caution") +local LITE_ROOT = arg[1] +if not LITE_ROOT then + error("LITE_ROOT is not given") +end +package.path = package.path .. ";" .. LITE_ROOT .. "/?.lua;" .. LITE_ROOT .. "/?/init.lua" + +-- fix core.command (because we don't want load the entire thing) +package.loaded["core.command"] = {} + +if #arg > 1 then + for i = 2, #arg do + process_module(arg[i], arg[i] .. ".json") + print(string.format("Exported keymap in %q.", arg[i])) + end +else + process_module(nil, "core.keymap.json") + print("Exported the default keymap.") +end diff --git a/scripts/package.sh b/scripts/package.sh index 1370aee8..21a8cc91 100644 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -186,7 +186,7 @@ main() { rm -rf "${dest_dir}" - DESTDIR="$(pwd)/${dest_dir}" meson install -C "${build_dir}" + DESTDIR="$(pwd)/${dest_dir}" meson install --skip-subprojects -C "${build_dir}" local data_dir="$(pwd)/${dest_dir}/data" local exe_file="$(pwd)/${dest_dir}/lite-xl" diff --git a/src/api/regex.c b/src/api/regex.c index 1043b1c5..9f6bd3ee 100644 --- a/src/api/regex.c +++ b/src/api/regex.c @@ -68,8 +68,11 @@ static int f_pcre_match(lua_State *L) { int rc = pcre2_match(re, (PCRE2_SPTR)str, len, offset - 1, opts, md, NULL); if (rc < 0) { pcre2_match_data_free(md); - if (rc != PCRE2_ERROR_NOMATCH) - luaL_error(L, "regex matching error %d", rc); + if (rc != PCRE2_ERROR_NOMATCH) { + PCRE2_UCHAR buffer[120]; + pcre2_get_error_message(rc, buffer, sizeof(buffer)); + luaL_error(L, "regex matching error %d: %s", rc, buffer); + } return 0; } PCRE2_SIZE* ovector = pcre2_get_ovector_pointer(md); diff --git a/src/api/renderer.c b/src/api/renderer.c index 60256118..ad3e763f 100644 --- a/src/api/renderer.c +++ b/src/api/renderer.c @@ -6,16 +6,18 @@ static int f_font_load(lua_State *L) { const char *filename = luaL_checkstring(L, 1); float size = luaL_checknumber(L, 2); unsigned int font_hinting = FONT_HINTING_SLIGHT, font_style = 0; - bool subpixel = true; + ERenFontAntialiasing font_antialiasing = FONT_ANTIALIASING_SUBPIXEL; if (lua_gettop(L) > 2 && lua_istable(L, 3)) { lua_getfield(L, 3, "antialiasing"); if (lua_isstring(L, -1)) { const char *antialiasing = lua_tostring(L, -1); if (antialiasing) { - if (strcmp(antialiasing, "grayscale") == 0) { - subpixel = false; + if (strcmp(antialiasing, "none") == 0) { + font_antialiasing = FONT_ANTIALIASING_NONE; + } else if (strcmp(antialiasing, "grayscale") == 0) { + font_antialiasing = FONT_ANTIALIASING_GRAYSCALE; } else if (strcmp(antialiasing, "subpixel") == 0) { - subpixel = true; + font_antialiasing = FONT_ANTIALIASING_SUBPIXEL; } else { return luaL_error(L, "error in renderer.font.load, unknown antialiasing option: \"%s\"", antialiasing); } @@ -48,7 +50,7 @@ static int f_font_load(lua_State *L) { lua_pop(L, 5); } RenFont** font = lua_newuserdata(L, sizeof(RenFont*)); - *font = ren_font_load(filename, size, subpixel, font_hinting, font_style); + *font = ren_font_load(filename, size, font_antialiasing, font_hinting, font_style); if (!*font) return luaL_error(L, "failed to load font"); luaL_setmetatable(L, API_TYPE_FONT); @@ -174,23 +176,30 @@ static int f_end_frame(lua_State *L) { } +static RenRect rect_to_grid(lua_Number x, lua_Number y, lua_Number w, lua_Number h) { + int x1 = (int) (x + 0.5), y1 = (int) (y + 0.5); + int x2 = (int) (x + w + 0.5), y2 = (int) (y + h + 0.5); + return (RenRect) {x1, y1, x2 - x1, y2 - y1}; +} + + static int f_set_clip_rect(lua_State *L) { - RenRect rect; - rect.x = luaL_checknumber(L, 1); - rect.y = luaL_checknumber(L, 2); - rect.width = luaL_checknumber(L, 3); - rect.height = luaL_checknumber(L, 4); + lua_Number x = luaL_checknumber(L, 1); + lua_Number y = luaL_checknumber(L, 2); + lua_Number w = luaL_checknumber(L, 3); + lua_Number h = luaL_checknumber(L, 4); + RenRect rect = rect_to_grid(x, y, w, h); rencache_set_clip_rect(rect); return 0; } static int f_draw_rect(lua_State *L) { - RenRect rect; - rect.x = luaL_checknumber(L, 1); - rect.y = luaL_checknumber(L, 2); - rect.width = luaL_checknumber(L, 3); - rect.height = luaL_checknumber(L, 4); + lua_Number x = luaL_checknumber(L, 1); + lua_Number y = luaL_checknumber(L, 2); + lua_Number w = luaL_checknumber(L, 3); + lua_Number h = luaL_checknumber(L, 4); + RenRect rect = rect_to_grid(x, y, w, h); RenColor color = checkcolor(L, 5, 255); rencache_draw_rect(rect, color); return 0; diff --git a/src/api/system.c b/src/api/system.c index 70b5b01c..8535bf37 100644 --- a/src/api/system.c +++ b/src/api/system.c @@ -6,11 +6,14 @@ #include #include #include "api.h" +#include "dirmonitor.h" #include "rencache.h" #ifdef _WIN32 #include #include #include +#elif __linux__ + #include #endif extern SDL_Window *window; @@ -18,9 +21,11 @@ extern SDL_Window *window; static const char* button_name(int button) { switch (button) { - case 1 : return "left"; - case 2 : return "middle"; - case 3 : return "right"; + case SDL_BUTTON_LEFT : return "left"; + case SDL_BUTTON_MIDDLE : return "middle"; + case SDL_BUTTON_RIGHT : return "right"; + case SDL_BUTTON_X1 : return "x"; + case SDL_BUTTON_X2 : return "y"; default : return "?"; } } @@ -236,6 +241,26 @@ top: lua_pushnumber(L, e.wheel.y); return 2; + case SDL_USEREVENT: + lua_pushstring(L, "dirchange"); + lua_pushnumber(L, e.user.code >> 16); + switch (e.user.code & 0xffff) { + case DMON_ACTION_DELETE: + lua_pushstring(L, "delete"); + break; + case DMON_ACTION_CREATE: + lua_pushstring(L, "create"); + break; + case DMON_ACTION_MODIFY: + lua_pushstring(L, "modify"); + break; + default: + return luaL_error(L, "unknown dmon event action: %d", e.user.code & 0xffff); + } + lua_pushstring(L, e.user.data1); + free(e.user.data1); + return 4; + default: goto top; } @@ -524,6 +549,45 @@ static int f_get_file_info(lua_State *L) { return 1; } +#if __linux__ +// https://man7.org/linux/man-pages/man2/statfs.2.html + +struct f_type_names { + uint32_t magic; + const char *name; +}; + +static struct f_type_names fs_names[] = { + { 0xef53, "ext2/ext3" }, + { 0x6969, "nfs" }, + { 0x65735546, "fuse" }, + { 0x517b, "smb" }, + { 0xfe534d42, "smb2" }, + { 0x52654973, "reiserfs" }, + { 0x01021994, "tmpfs" }, + { 0x858458f6, "ramfs" }, + { 0x5346544e, "ntfs" }, + { 0x0, NULL }, +}; + +static int f_get_fs_type(lua_State *L) { + const char *path = luaL_checkstring(L, 1); + struct statfs buf; + int status = statfs(path, &buf); + if (status != 0) { + return luaL_error(L, "error calling statfs on %s", path); + } + for (int i = 0; fs_names[i].magic; i++) { + if (fs_names[i].magic == buf.f_type) { + lua_pushstring(L, fs_names[i].name); + return 1; + } + } + lua_pushstring(L, "unknown"); + return 1; +} +#endif + static int f_mkdir(lua_State *L) { const char *path = luaL_checkstring(L, 1); @@ -591,56 +655,32 @@ static int f_exec(lua_State *L) { return 0; } - static int f_fuzzy_match(lua_State *L) { size_t strLen, ptnLen; const char *str = luaL_checklstring(L, 1, &strLen); const char *ptn = luaL_checklstring(L, 2, &ptnLen); - bool files = false; - if (lua_gettop(L) > 2 && lua_isboolean(L,3)) - files = lua_toboolean(L, 3); - - int score = 0; - int run = 0; - - // Match things *backwards*. This allows for better matching on filenames than the above + // If true match things *backwards*. This allows for better matching on filenames than the above // function. For example, in the lite project, opening "renderer" has lib/font_render/build.sh // as the first result, rather than src/renderer.c. Clearly that's wrong. - if (files) { - const char* strEnd = str + strLen - 1; - const char* ptnEnd = ptn + ptnLen - 1; - while (strEnd >= str && ptnEnd >= ptn) { - while (*strEnd == ' ') { strEnd--; } - while (*ptnEnd == ' ') { ptnEnd--; } - if (tolower(*strEnd) == tolower(*ptnEnd)) { - score += run * 10 - (*strEnd != *ptnEnd); - run++; - ptnEnd--; - } else { - score -= 10; - run = 0; - } - strEnd--; + bool files = lua_gettop(L) > 2 && lua_isboolean(L,3) && lua_toboolean(L, 3); + int score = 0, run = 0, increment = files ? -1 : 1; + const char* strTarget = files ? str + strLen - 1 : str; + const char* ptnTarget = files ? ptn + ptnLen - 1 : ptn; + while (strTarget >= str && ptnTarget >= ptn && *strTarget && *ptnTarget) { + while (strTarget >= str && *strTarget == ' ') { strTarget += increment; } + while (ptnTarget >= ptn && *ptnTarget == ' ') { ptnTarget += increment; } + if (tolower(*strTarget) == tolower(*ptnTarget)) { + score += run * 10 - (*strTarget != *ptnTarget); + run++; + ptnTarget += increment; + } else { + score -= 10; + run = 0; } - if (ptnEnd >= ptn) { return 0; } - } else { - while (*str && *ptn) { - while (*str == ' ') { str++; } - while (*ptn == ' ') { ptn++; } - if (tolower(*str) == tolower(*ptn)) { - score += run * 10 - (*str != *ptn); - run++; - ptn++; - } else { - score -= 10; - run = 0; - } - str++; - } - if (*ptn) { return 0; } + strTarget += increment; } - - lua_pushnumber(L, score - (int)strLen); + if (ptnTarget >= ptn && *ptnTarget) { return 0; } + lua_pushnumber(L, score - (int)strLen * 10); return 1; } @@ -738,6 +778,91 @@ static int f_load_native_plugin(lua_State *L) { return result; } +static int f_watch_dir(lua_State *L) { + const char *path = luaL_checkstring(L, 1); + const int recursive = lua_toboolean(L, 2); + uint32_t dmon_flags = (recursive ? DMON_WATCHFLAGS_RECURSIVE : 0); + dmon_watch_id watch_id = dmon_watch(path, dirmonitor_watch_callback, dmon_flags, NULL); + if (watch_id.id == 0) { luaL_error(L, "directory monitoring watch failed"); } + lua_pushnumber(L, watch_id.id); + return 1; +} + +#if __linux__ +static int f_watch_dir_add(lua_State *L) { + dmon_watch_id watch_id; + watch_id.id = luaL_checkinteger(L, 1); + const char *subdir = luaL_checkstring(L, 2); + lua_pushboolean(L, dmon_watch_add(watch_id, subdir)); + return 1; +} + +static int f_watch_dir_rm(lua_State *L) { + dmon_watch_id watch_id; + watch_id.id = luaL_checkinteger(L, 1); + const char *subdir = luaL_checkstring(L, 2); + lua_pushboolean(L, dmon_watch_rm(watch_id, subdir)); + return 1; +} +#endif + +#ifdef _WIN32 +#define PATHSEP '\\' +#else +#define PATHSEP '/' +#endif + +/* Special purpose filepath compare function. Corresponds to the + order used in the TreeView view of the project's files. Returns true iff + path1 < path2 in the TreeView order. */ +static int f_path_compare(lua_State *L) { + const char *path1 = luaL_checkstring(L, 1); + const char *type1_s = luaL_checkstring(L, 2); + const char *path2 = luaL_checkstring(L, 3); + const char *type2_s = luaL_checkstring(L, 4); + const int len1 = strlen(path1), len2 = strlen(path2); + int type1 = strcmp(type1_s, "dir") != 0; + int type2 = strcmp(type2_s, "dir") != 0; + /* Find the index of the common part of the path. */ + int offset = 0, i; + for (i = 0; i < len1 && i < len2; i++) { + if (path1[i] != path2[i]) break; + if (path1[i] == PATHSEP) { + offset = i + 1; + } + } + /* If a path separator is present in the name after the common part we consider + the entry like a directory. */ + if (strchr(path1 + offset, PATHSEP)) { + type1 = 0; + } + if (strchr(path2 + offset, PATHSEP)) { + type2 = 0; + } + /* If types are different "dir" types comes before "file" types. */ + if (type1 != type2) { + lua_pushboolean(L, type1 < type2); + return 1; + } + /* If types are the same compare the files' path alphabetically. */ + int cfr = 0; + int len_min = (len1 < len2 ? len1 : len2); + for (int j = offset; j <= len_min; j++) { + if (path1[j] == path2[j]) continue; + if (path1[j] == 0 || path2[j] == 0) { + cfr = (path1[j] == 0); + } else if (path1[j] == PATHSEP || path2[j] == PATHSEP) { + /* For comparison we treat PATHSEP as if it was the string terminator. */ + cfr = (path1[j] == PATHSEP); + } else { + cfr = (path1[j] < path2[j]); + } + break; + } + lua_pushboolean(L, cfr); + return 1; +} + static const luaL_Reg lib[] = { { "poll_event", f_poll_event }, @@ -766,6 +891,13 @@ static const luaL_Reg lib[] = { { "fuzzy_match", f_fuzzy_match }, { "set_window_opacity", f_set_window_opacity }, { "load_native_plugin", f_load_native_plugin }, + { "watch_dir", f_watch_dir }, + { "path_compare", f_path_compare }, +#if __linux__ + { "watch_dir_add", f_watch_dir_add }, + { "watch_dir_rm", f_watch_dir_rm }, + { "get_fs_type", f_get_fs_type }, +#endif { NULL, NULL } }; diff --git a/src/dirmonitor.c b/src/dirmonitor.c new file mode 100644 index 00000000..0063e400 --- /dev/null +++ b/src/dirmonitor.c @@ -0,0 +1,59 @@ +#include +#include + +#include + +#define DMON_IMPL +#include "dmon.h" +#include "dmon_extra.h" + +#include "dirmonitor.h" + +static void send_sdl_event(dmon_watch_id watch_id, dmon_action action, const char *filepath) { + SDL_Event ev; + const int size = strlen(filepath) + 1; + /* The string allocated below should be deallocated as soon as the event is + treated in the SDL main loop. */ + char *new_filepath = malloc(size); + if (!new_filepath) return; + memcpy(new_filepath, filepath, size); +#ifdef _WIN32 + for (int i = 0; i < size; i++) { + if (new_filepath[i] == '/') { + new_filepath[i] = '\\'; + } + } +#endif + SDL_zero(ev); + ev.type = SDL_USEREVENT; + ev.user.code = ((watch_id.id & 0xffff) << 16) | (action & 0xffff); + ev.user.data1 = new_filepath; + SDL_PushEvent(&ev); +} + +void dirmonitor_init() { + dmon_init(); + /* In theory we should register our user event but since we + have just one type of user event this is not really needed. */ + /* sdl_dmon_event_type = SDL_RegisterEvents(1); */ +} + +void dirmonitor_deinit() { + dmon_deinit(); +} + +void dirmonitor_watch_callback(dmon_watch_id watch_id, dmon_action action, const char *rootdir, + const char *filepath, const char *oldfilepath, void *user) +{ + (void) rootdir; + (void) user; + switch (action) { + case DMON_ACTION_MOVE: + send_sdl_event(watch_id, DMON_ACTION_DELETE, oldfilepath); + send_sdl_event(watch_id, DMON_ACTION_CREATE, filepath); + break; + default: + send_sdl_event(watch_id, action, filepath); + } +} + diff --git a/src/dirmonitor.h b/src/dirmonitor.h new file mode 100644 index 00000000..074a9ae8 --- /dev/null +++ b/src/dirmonitor.h @@ -0,0 +1,15 @@ +#ifndef DIRMONITOR_H +#define DIRMONITOR_H + +#include + +#include "dmon.h" +#include "dmon_extra.h" + +void dirmonitor_init(); +void dirmonitor_deinit(); +void dirmonitor_watch_callback(dmon_watch_id watch_id, dmon_action action, const char *rootdir, + const char *filepath, const char *oldfilepath, void *user); + +#endif + diff --git a/src/main.c b/src/main.c index 99aa580f..9fcbeab6 100644 --- a/src/main.c +++ b/src/main.c @@ -14,6 +14,8 @@ #include #endif +#include "dirmonitor.h" + SDL_Window *window; @@ -106,6 +108,8 @@ int main(int argc, char **argv) { SDL_DisplayMode dm; SDL_GetCurrentDisplayMode(0, &dm); + dirmonitor_init(); + window = SDL_CreateWindow( "", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, dm.w * 0.8, dm.h * 0.8, SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI | SDL_WINDOW_HIDDEN); @@ -188,6 +192,7 @@ init_lua: lua_close(L); ren_free_window_resources(); + dirmonitor_deinit(); return EXIT_SUCCESS; } diff --git a/src/meson.build b/src/meson.build index 6fefd257..1eaf87fd 100644 --- a/src/meson.build +++ b/src/meson.build @@ -4,6 +4,7 @@ lite_sources = [ 'api/regex.c', 'api/system.c', 'api/process.c', + 'dirmonitor.c', 'renderer.c', 'renwindow.c', 'rencache.c', @@ -18,11 +19,11 @@ elif host_machine.system() == 'darwin' lite_sources += 'bundle_open.m' endif -lite_include = include_directories('.') +lite_includes += include_directories('.') executable('lite-xl', lite_sources + lite_rc, - include_directories: [lite_include], + include_directories: lite_includes, dependencies: lite_deps, c_args: lite_cargs, objc_args: lite_cargs, diff --git a/src/rencache.c b/src/rencache.c index c3254cd0..e9339ecb 100644 --- a/src/rencache.c +++ b/src/rencache.c @@ -123,7 +123,9 @@ void rencache_set_clip_rect(RenRect rect) { void rencache_draw_rect(RenRect rect, RenColor color) { - if (!rects_overlap(screen_rect, rect)) { return; } + if (!rects_overlap(screen_rect, rect) || rect.width == 0 || rect.height == 0) { + return; + } Command *cmd = push_command(DRAW_RECT, COMMAND_BARE_SIZE); if (cmd) { cmd->rect = rect; diff --git a/src/renderer.c b/src/renderer.c index 7cd88237..b0e17f58 100644 --- a/src/renderer.c +++ b/src/renderer.c @@ -43,7 +43,7 @@ typedef struct RenFont { GlyphSet* sets[SUBPIXEL_BITMAPS_CACHED][MAX_LOADABLE_GLYPHSETS]; float size, space_advance, tab_advance; short max_height; - bool subpixel; + ERenFontAntialiasing antialiasing; ERenFontHinting hinting; unsigned char style; char path[0]; @@ -66,16 +66,16 @@ static const char* utf8_to_codepoint(const char *p, unsigned *dst) { } static int font_set_load_options(RenFont* font) { - switch (font->hinting) { - case FONT_HINTING_SLIGHT: return FT_LOAD_TARGET_LIGHT | FT_LOAD_FORCE_AUTOHINT; - case FONT_HINTING_FULL: return FT_LOAD_TARGET_NORMAL | FT_LOAD_FORCE_AUTOHINT; - case FONT_HINTING_NONE: return FT_LOAD_TARGET_NORMAL | FT_LOAD_NO_HINTING; - } - return FT_LOAD_TARGET_NORMAL | FT_LOAD_NO_HINTING; + int load_target = font->antialiasing == FONT_ANTIALIASING_NONE ? FT_LOAD_TARGET_MONO + : (font->hinting == FONT_HINTING_SLIGHT ? FT_LOAD_TARGET_LIGHT : FT_LOAD_TARGET_NORMAL); + int hinting = font->hinting == FONT_HINTING_NONE ? FT_LOAD_NO_HINTING : FT_LOAD_FORCE_AUTOHINT; + return load_target | hinting; } static int font_set_render_options(RenFont* font) { - if (font->subpixel) { + if (font->antialiasing == FONT_ANTIALIASING_NONE) + return FT_RENDER_MODE_MONO; + if (font->antialiasing == FONT_ANTIALIASING_SUBPIXEL) { unsigned char weights[] = { 0x10, 0x40, 0x70, 0x40, 0x10 } ; switch (font->hinting) { case FONT_HINTING_NONE: FT_Library_SetLcdFilter(library, FT_LCD_FILTER_NONE); break; @@ -106,8 +106,8 @@ static int font_set_style(FT_Outline* outline, int x_translation, unsigned char static void font_load_glyphset(RenFont* font, int idx) { unsigned int render_option = font_set_render_options(font), load_option = font_set_load_options(font); - int bitmaps_cached = font->subpixel ? SUBPIXEL_BITMAPS_CACHED : 1; - unsigned int byte_width = font->subpixel ? 3 : 1; + int bitmaps_cached = font->antialiasing == FONT_ANTIALIASING_SUBPIXEL ? SUBPIXEL_BITMAPS_CACHED : 1; + unsigned int byte_width = font->antialiasing == FONT_ANTIALIASING_SUBPIXEL ? 3 : 1; for (int j = 0, pen_x = 0; j < bitmaps_cached; ++j) { GlyphSet* set = check_alloc(calloc(1, sizeof(GlyphSet))); font->sets[j][idx] = set; @@ -117,13 +117,15 @@ static void font_load_glyphset(RenFont* font, int idx) { continue; FT_GlyphSlot slot = font->face->glyph; int glyph_width = slot->bitmap.width / byte_width; + if (font->antialiasing == FONT_ANTIALIASING_NONE) + glyph_width *= 8; set->metrics[i] = (GlyphMetric){ pen_x, pen_x + glyph_width, 0, slot->bitmap.rows, true, slot->bitmap_left, slot->bitmap_top, (slot->advance.x + slot->lsb_delta - slot->rsb_delta) / 64.0f}; pen_x += glyph_width; font->max_height = slot->bitmap.rows > font->max_height ? slot->bitmap.rows : font->max_height; } if (pen_x == 0) continue; - set->surface = check_alloc(SDL_CreateRGBSurface(0, pen_x, font->max_height, font->subpixel ? 24 : 8, 0, 0, 0, 0)); + set->surface = check_alloc(SDL_CreateRGBSurface(0, pen_x, font->max_height, font->antialiasing == FONT_ANTIALIASING_SUBPIXEL ? 24 : 8, 0, 0, 0, 0)); unsigned char* pixels = set->surface->pixels; for (int i = 0; i < MAX_GLYPHSET; ++i) { int glyph_index = FT_Get_Char_Index(font->face, i + idx * MAX_GLYPHSET); @@ -136,7 +138,14 @@ static void font_load_glyphset(RenFont* font, int idx) { for (int line = 0; line < slot->bitmap.rows; ++line) { int target_offset = set->surface->pitch * line + set->metrics[i].x0 * byte_width; int source_offset = line * slot->bitmap.pitch; - memcpy(&pixels[target_offset], &slot->bitmap.buffer[source_offset], slot->bitmap.width); + if (font->antialiasing == FONT_ANTIALIASING_NONE) { + for (int column = 0; column < slot->bitmap.width; ++column) { + int current_source_offset = source_offset + (column / 8); + int source_pixel = slot->bitmap.buffer[current_source_offset]; + pixels[++target_offset] = ((source_pixel >> (7 - (column % 8))) & 0x1) << 7; + } + } else + memcpy(&pixels[target_offset], &slot->bitmap.buffer[source_offset], slot->bitmap.width); } } } @@ -144,9 +153,9 @@ static void font_load_glyphset(RenFont* font, int idx) { static GlyphSet* font_get_glyphset(RenFont* font, unsigned int codepoint, int subpixel_idx) { int idx = (codepoint >> 8) % MAX_LOADABLE_GLYPHSETS; - if (!font->sets[font->subpixel ? subpixel_idx : 0][idx]) + if (!font->sets[font->antialiasing == FONT_ANTIALIASING_SUBPIXEL ? subpixel_idx : 0][idx]) font_load_glyphset(font, idx); - return font->sets[font->subpixel ? subpixel_idx : 0][idx]; + return font->sets[font->antialiasing == FONT_ANTIALIASING_SUBPIXEL ? subpixel_idx : 0][idx]; } static RenFont* font_group_get_glyph(GlyphSet** set, GlyphMetric** metric, RenFont** fonts, unsigned int codepoint, int bitmap_index) { @@ -163,7 +172,7 @@ static RenFont* font_group_get_glyph(GlyphSet** set, GlyphMetric** metric, RenFo return fonts[0]; } -RenFont* ren_font_load(const char* path, float size, bool subpixel, unsigned char hinting, unsigned char style) { +RenFont* ren_font_load(const char* path, float size, ERenFontAntialiasing antialiasing, ERenFontHinting hinting, unsigned char style) { FT_Face face; if (FT_New_Face( library, path, 0, &face)) return NULL; @@ -175,7 +184,7 @@ RenFont* ren_font_load(const char* path, float size, bool subpixel, unsigned cha strcpy(font->path, path); font->face = face; font->size = size; - font->subpixel = subpixel; + font->antialiasing = antialiasing; font->hinting = hinting; font->style = style; font->space_advance = (int)font_get_glyphset(font, ' ', 0)->metrics[' '].xadvance; @@ -187,7 +196,7 @@ RenFont* ren_font_load(const char* path, float size, bool subpixel, unsigned cha } RenFont* ren_font_copy(RenFont* font, float size) { - return ren_font_load(font->path, size, font->subpixel, font->hinting, font->style); + return ren_font_load(font->path, size, font->antialiasing, font->hinting, font->style); } void ren_font_free(RenFont* font) { @@ -206,7 +215,7 @@ void ren_font_free(RenFont* font) { void ren_font_group_set_tab_size(RenFont **fonts, int n) { for (int j = 0; j < FONT_FALLBACK_MAX && fonts[j]; ++j) { - for (int i = 0; i < (fonts[j]->subpixel ? SUBPIXEL_BITMAPS_CACHED : 1); ++i) + for (int i = 0; i < (fonts[j]->antialiasing == FONT_ANTIALIASING_SUBPIXEL ? SUBPIXEL_BITMAPS_CACHED : 1); ++i) font_get_glyphset(fonts[j], '\t', i)->metrics['\t'].xadvance = fonts[j]->space_advance * n; } } @@ -247,6 +256,7 @@ float ren_draw_text(RenFont **fonts, const char *text, float x, int y, RenColor const char* end = text + strlen(text); unsigned char* destination_pixels = surface->pixels; int clip_end_x = clip.x + clip.width, clip_end_y = clip.y + clip.height; + while (text < end) { unsigned int codepoint, r, g, b; text = utf8_to_codepoint(text, &codepoint); @@ -273,15 +283,15 @@ float ren_draw_text(RenFont **fonts, const char *text, float x, int y, RenColor glyph_start += offset; } unsigned int* destination_pixel = (unsigned int*)&destination_pixels[surface->pitch * target_y + start_x * bytes_per_pixel]; - unsigned char* source_pixel = &source_pixels[line * set->surface->pitch + glyph_start * (font->subpixel ? 3 : 1)]; + unsigned char* source_pixel = &source_pixels[line * set->surface->pitch + glyph_start * (font->antialiasing == FONT_ANTIALIASING_SUBPIXEL ? 3 : 1)]; for (int x = glyph_start; x < glyph_end; ++x) { unsigned int destination_color = *destination_pixel; - SDL_Color dst = { (destination_color >> 16) & 0xFF, (destination_color >> 8) & 0xFF, (destination_color >> 0) & 0xFF, (destination_color >> 24) & 0xFF }; - SDL_Color src = { *(font->subpixel ? source_pixel++ : source_pixel), *(font->subpixel ? source_pixel++ : source_pixel), *source_pixel++ }; + SDL_Color dst = { (destination_color & surface->format->Rmask) >> surface->format->Rshift, (destination_color & surface->format->Gmask) >> surface->format->Gshift, (destination_color & surface->format->Bmask) >> surface->format->Bshift, (destination_color & surface->format->Amask) >> surface->format->Ashift }; + SDL_Color src = { *(font->antialiasing == FONT_ANTIALIASING_SUBPIXEL ? source_pixel++ : source_pixel), *(font->antialiasing == FONT_ANTIALIASING_SUBPIXEL ? source_pixel++ : source_pixel), *source_pixel++ }; r = (color.r * src.r * color.a + dst.r * (65025 - src.r * color.a) + 32767) / 65025; g = (color.g * src.g * color.a + dst.g * (65025 - src.g * color.a) + 32767) / 65025; b = (color.b * src.b * color.a + dst.b * (65025 - src.b * color.a) + 32767) / 65025; - *destination_pixel++ = dst.a << 24 | r << 16 | g << 8 | b; + *destination_pixel++ = dst.a << surface->format->Ashift | r << surface->format->Rshift | g << surface->format->Gshift | b << surface->format->Bshift; } } } @@ -324,14 +334,15 @@ void ren_draw_rect(RenRect rect, RenColor color) { RenColor *d = (RenColor*) surface->pixels; d += x1 + y1 * surface->w; int dr = surface->w - (x2 - x1); - + unsigned int translated = SDL_MapRGB(surface->format, color.r, color.g, color.b); if (color.a == 0xff) { SDL_Rect rect = { x1, y1, x2 - x1, y2 - y1 }; - SDL_FillRect(surface, &rect, SDL_MapRGBA(surface->format, color.r, color.g, color.b, color.a)); + SDL_FillRect(surface, &rect, translated); } else { + RenColor translated_color = (RenColor){ translated & 0xFF, (translated >> 8) & 0xFF, (translated >> 16) & 0xFF, color.a }; for (int j = y1; j < y2; j++) { for (int i = x1; i < x2; i++, d++) - *d = blend_pixel(*d, color); + *d = blend_pixel(*d, translated_color); d += dr; } } diff --git a/src/renderer.h b/src/renderer.h index 6058583e..a97706ff 100644 --- a/src/renderer.h +++ b/src/renderer.h @@ -8,11 +8,12 @@ #define FONT_FALLBACK_MAX 4 typedef struct RenFont RenFont; typedef enum { FONT_HINTING_NONE, FONT_HINTING_SLIGHT, FONT_HINTING_FULL } ERenFontHinting; +typedef enum { FONT_ANTIALIASING_NONE, FONT_ANTIALIASING_GRAYSCALE, FONT_ANTIALIASING_SUBPIXEL } ERenFontAntialiasing; typedef enum { FONT_STYLE_BOLD = 1, FONT_STYLE_ITALIC = 2, FONT_STYLE_UNDERLINE = 4 } ERenFontStyle; typedef struct { uint8_t b, g, r, a; } RenColor; typedef struct { int x, y, width, height; } RenRect; -RenFont* ren_font_load(const char *filename, float size, bool subpixel, unsigned char hinting, unsigned char style); +RenFont* ren_font_load(const char *filename, float size, ERenFontAntialiasing antialiasing, ERenFontHinting hinting, unsigned char style); RenFont* ren_font_copy(RenFont* font, float size); void ren_font_free(RenFont *font); int ren_font_group_get_tab_size(RenFont **font);