From 86632b68dec51e84b2daed3ca73a2f262b071538 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Thu, 16 Sep 2021 23:26:11 +0200 Subject: [PATCH 01/72] Move single tab drawing to its own function --- data/core/rootview.lua | 91 +++++++++++++++++++++++------------------- 1 file changed, 50 insertions(+), 41 deletions(-) diff --git a/data/core/rootview.lua b/data/core/rootview.lua index 0856a619..8a927077 100644 --- a/data/core/rootview.lua +++ b/data/core/rootview.lua @@ -513,6 +513,53 @@ function Node:update() end end +function Node:draw_tab(text, is_active, is_hovered, is_close_hovered, x, y, w, h, standalone) + local ds = style.divider_size + local dots_width = style.font:get_width("…") + local color = style.dim + local padding_y = style.padding.y + renderer.draw_rect(x + w, y + padding_y, ds, h - padding_y * 2, style.dim) + if standalone then + renderer.draw_rect(x-1, y-1, w+2, h+2, style.background2) + end + if is_active then + color = style.text + renderer.draw_rect(x, y, w, h, style.background) + renderer.draw_rect(x + w, y, ds, h, style.divider) + renderer.draw_rect(x - ds, y, ds, h, style.divider) + end + local cx, cw, cspace = close_button_location(x, w) + local show_close_button = ((is_active or is_hovered) and not standalone and config.tab_close_button) + if show_close_button then + local close_style = is_close_hovered and style.text or style.dim + common.draw_text(style.icon_font, close_style, "C", nil, cx, y, 0, h) + end + if is_hovered then + color = style.text + end + local padx = style.padding.x + -- Normally we should substract "cspace" from text_avail_width and from the + -- clipping width. It is the padding space we give to the left and right of the + -- close button. However, since we are using dots to terminate filenames, we + -- choose to ignore "cspace" accepting that the text can possibly "touch" the + -- close button. + local text_avail_width = cx - x - padx + core.push_clip_rect(x, y, cx - x, h) + x, w = x + padx, w - padx * 2 + local align = "center" + if style.font:get_width(text) > text_avail_width then + align = "left" + for i = 1, #text do + local reduced_text = text:sub(1, #text - i) + if style.font:get_width(reduced_text) + dots_width <= text_avail_width then + text = reduced_text .. "…" + break + end + end + end + common.draw_text(style.font, color, text, align, x, y, w, h) + core.pop_clip_rect() +end function Node:draw_tabs() local x, y, w, h, scroll_padding = self:get_scroll_button_rect(1) @@ -537,47 +584,9 @@ function Node:draw_tabs() for i = self.tab_offset, self.tab_offset + tabs_number - 1 do local view = self.views[i] local x, y, w, h = self:get_tab_rect(i) - local text = view:get_name() - local color = style.dim - local padding_y = style.padding.y - renderer.draw_rect(x + w, y + padding_y, ds, h - padding_y * 2, style.dim) - if view == self.active_view then - color = style.text - renderer.draw_rect(x, y, w, h, style.background) - renderer.draw_rect(x + w, y, ds, h, style.divider) - renderer.draw_rect(x - ds, y, ds, h, style.divider) - end - local cx, cw, cspace = close_button_location(x, w) - local show_close_button = ((view == self.active_view or i == self.hovered_tab) and config.tab_close_button) - if show_close_button then - local close_style = self.hovered_close == i and style.text or style.dim - common.draw_text(style.icon_font, close_style, "C", nil, cx, y, 0, h) - end - if i == self.hovered_tab then - color = style.text - end - local padx = style.padding.x - -- Normally we should substract "cspace" from text_avail_width and from the - -- clipping width. It is the padding space we give to the left and right of the - -- close button. However, since we are using dots to terminate filenames, we - -- choose to ignore "cspace" accepting that the text can possibly "touch" the - -- close button. - local text_avail_width = cx - x - padx - core.push_clip_rect(x, y, cx - x, h) - x, w = x + padx, w - padx * 2 - local align = "center" - if style.font:get_width(text) > text_avail_width then - align = "left" - for i = 1, #text do - local reduced_text = text:sub(1, #text - i) - if style.font:get_width(reduced_text) + dots_width <= text_avail_width then - text = reduced_text .. "…" - break - end - end - end - common.draw_text(style.font, color, text, align, x, y, w, h) - core.pop_clip_rect() + self:draw_tab(view:get_name(), view == self.active_view, + i == self.hovered_tab, i == self.hovered_close, + x, y, w, h) end core.pop_clip_rect() From dced6da03dbded2ca0814ca9a6a8a2e5ac698daa Mon Sep 17 00:00:00 2001 From: Guldoman Date: Fri, 17 Sep 2021 02:47:34 +0200 Subject: [PATCH 02/72] Implement tab drag and drop --- data/core/common.lua | 5 + data/core/rootview.lua | 272 +++++++++++++++++++++++++++++++++++++---- data/core/style.lua | 2 + 3 files changed, 256 insertions(+), 23 deletions(-) diff --git a/data/core/common.lua b/data/core/common.lua index 9f3102bb..1a1b22cd 100644 --- a/data/core/common.lua +++ b/data/core/common.lua @@ -41,6 +41,11 @@ function common.lerp(a, b, t) end +function common.distance(x1, y1, x2, y2) + return math.sqrt(math.pow(x2-x1, 2)+math.pow(y2-y1, 2)) +end + + function common.color(str) local r, g, b, a = str:match("#(%x%x)(%x%x)(%x%x)") if r then diff --git a/data/core/rootview.lua b/data/core/rootview.lua index 8a927077..bf438aa5 100644 --- a/data/core/rootview.lua +++ b/data/core/rootview.lua @@ -712,6 +712,62 @@ function Node:resize(axis, value) end +function Node:get_split_type(mouse_x, mouse_y) + local x, y = self.position.x, self.position.y + local w, h = self.size.x, self.size.y + local _, _, _, tab_h = self:get_scroll_button_rect(1) + y = y + tab_h + h = h - tab_h + + local local_mouse_x = mouse_x - x + local local_mouse_y = mouse_y - y + + if local_mouse_y < 0 then + return "tab" + else + local left_pct = local_mouse_x * 100 / w + local top_pct = local_mouse_y * 100 / h + if left_pct <= 30 then + return "left" + elseif left_pct >= 70 then + return "right" + elseif top_pct <= 30 then + return "up" + elseif top_pct >= 70 then + return "down" + end + return "middle" + end +end + + +function Node:get_drag_overlay_tab_position(x, y, dragged_node, dragged_index) + local tab_index = self:get_tab_overlapping_point(x, y) + if not tab_index then + local first_tab_x = self:get_tab_rect(1) + if x < first_tab_x then + -- mouse before first visible tab + tab_index = self.tab_offset or 1 + else + -- mouse after last visible tab + tab_index = self:get_visible_tabs_number() + (self.tab_offset - 1 or 0) + end + end + local tab_x, tab_y, tab_w, tab_h = self:get_tab_rect(tab_index) + if x > tab_x + tab_w / 2 and tab_index <= #self.views then + -- use next tab + tab_x = tab_x + tab_w + tab_index = tab_index + 1 + end + if self == dragged_node and dragged_index and tab_index > dragged_index then + -- the tab we are moving is counted in tab_index + tab_index = tab_index - 1 + tab_x = tab_x - tab_w + end + return tab_index, tab_x, tab_y, tab_w, tab_h +end + + local RootView = View:extend() function RootView:new() @@ -719,6 +775,14 @@ function RootView:new() self.root_node = Node() self.deferred_draws = {} self.mouse = { x = 0, y = 0 } + self.drag_overlay = { x = 0, y = 0, w = 0, h = 0, visible = false, opacity = 0, + base_color = style.drag_overlay, + color = { table.unpack(style.drag_overlay) } } + self.drag_overlay.to = { x = 0, y = 0, w = 0, h = 0 } + self.drag_overlay_tab = { x = 0, y = 0, w = 0, h = 0, visible = false, opacity = 0, + base_color = style.drag_overlay_tab, + color = { table.unpack(style.drag_overlay_tab) } } + self.drag_overlay_tab.to = { x = 0, y = 0, w = 0, h = 0 } end @@ -802,10 +866,12 @@ function RootView:on_mouse_pressed(button, x, y, clicks) if button == "middle" or node.hovered_close == idx then node:close_view(self.root_node, node.views[idx]) else - self.dragged_node = { node, idx } + 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]) end - else + 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) @@ -814,14 +880,73 @@ function RootView:on_mouse_pressed(button, x, y, clicks) end -function RootView:on_mouse_released(...) +function RootView:get_overlay_base_color(overlay) + if overlay == self.drag_overlay then + return style.drag_overlay + else + return style.drag_overlay_tab + end +end + + +function RootView:set_show_overlay(overlay, status) + overlay.visible = status + if status then -- reset colors + -- reload base_color + overlay.base_color = self:get_overlay_base_color(overlay) + overlay.color[1] = overlay.base_color[1] + overlay.color[2] = overlay.base_color[2] + overlay.color[3] = overlay.base_color[3] + overlay.color[4] = overlay.base_color[4] + overlay.opacity = 0 + end +end + + +function RootView:on_mouse_released(button, x, y, ...) if self.dragged_divider then self.dragged_divider = nil end if self.dragged_node then - self.dragged_node = nil + if button == "left" then + if self.dragged_node.dragging then + local node = self.root_node:get_child_overlapping_point(self.mouse.x, self.mouse.y) + local dragged_node = self.dragged_node.node + + if node and not node.locked + -- don't do anything if dragging onto own node, with only one view + and (node ~= dragged_node or #node.views > 1) then + local split_type = node:get_split_type(self.mouse.x, self.mouse.y) + local view = dragged_node.views[self.dragged_node.idx] + + if split_type ~= "middle" and split_type ~= "tab" then -- needs splitting + local new_node = node:split(split_type) + self.root_node:get_node_for_view(view):remove_view(self.root_node, view) + new_node:add_view(view) + elseif split_type == "middle" and node ~= dragged_node then -- move to other node + dragged_node:remove_view(self.root_node, view) + node:add_view(view) + self.root_node:get_node_for_view(view):set_active_view(view) + elseif split_type == "tab" then -- move besides other tabs + local tab_index = node:get_drag_overlay_tab_position(self.mouse.x, self.mouse.y, dragged_node, self.dragged_node.idx) + dragged_node:remove_view(self.root_node, view) + node:add_view(view, tab_index) + self.root_node:get_node_for_view(view):set_active_view(view) + end + self.root_node:update_layout() + core.redraw = true + end + end + self:set_show_overlay(self.drag_overlay, false) + self:set_show_overlay(self.drag_overlay_tab, false) + if self.dragged_node and self.dragged_node.dragging then + core.request_cursor("arrow") + end + self.dragged_node = nil + end + else -- avoid sending on_mouse_released events when dragging tabs + self.root_node:on_mouse_released(button, x, y, ...) end - self.root_node:on_mouse_released(...) end @@ -857,6 +982,19 @@ function RootView:on_mouse_moved(x, y, dx, dy) end self.mouse.x, self.mouse.y = x, y + + local dn = self.dragged_node + if dn and not dn.dragging then + -- start dragging only after enough movement + dn.dragging = common.distance(x, y, dn.drag_start_x, dn.drag_start_y) > style.tab_width * .05 + if dn.dragging then + core.request_cursor("hand") + end + end + + -- avoid sending on_mouse_moved events when dragging tabs + if dn then return end + self.root_node:on_mouse_moved(x, y, dx, dy) local node = self.root_node:get_child_overlapping_point(x, y) @@ -871,24 +1009,6 @@ function RootView:on_mouse_moved(x, y, dx, dy) elseif node then core.request_cursor(node.active_view.cursor) end - if node and self.dragged_node and (self.dragged_node[1] ~= node or (tab_index and self.dragged_node[2] ~= tab_index)) - and node.type == "leaf" and #node.views > 0 and node.views[1]:is(DocView) then - local tab = self.dragged_node[1].views[self.dragged_node[2]] - if self.dragged_node[1] ~= node then - for i, v in ipairs(node.views) do if v.doc == tab.doc then tab = nil break end end - if tab then - self.dragged_node[1]:remove_view(self.root_node, tab) - node:add_view(tab, tab_index) - self.root_node:update_layout() - self.dragged_node = { node, tab_index or #node.views } - core.redraw = true - end - else - table.remove(self.dragged_node[1].views, self.dragged_node[2]) - table.insert(node.views, tab_index, tab) - self.dragged_node = { node, tab_index } - end - end end @@ -909,10 +1029,110 @@ function RootView:on_focus_lost(...) core.redraw = true end + +function RootView:interpolate_drag_overlay(overlay) + self:move_towards(overlay, "x", overlay.to.x) + self:move_towards(overlay, "y", overlay.to.y) + self:move_towards(overlay, "w", overlay.to.w) + self:move_towards(overlay, "h", overlay.to.h) + + self:move_towards(overlay, "opacity", overlay.visible and 100 or 0) + overlay.color[4] = overlay.base_color[4] * overlay.opacity / 100 +end + + function RootView:update() copy_position_and_size(self.root_node, self) self.root_node:update() self.root_node:update_layout() + + self:update_drag_overlay() + self:interpolate_drag_overlay(self.drag_overlay) + self:interpolate_drag_overlay(self.drag_overlay_tab) +end + + +function RootView:set_drag_overlay(overlay, x, y, w, h, immediate) + overlay.to.x = x + overlay.to.y = y + overlay.to.w = w + overlay.to.h = h + if immediate then + overlay.x = x + overlay.y = y + overlay.w = w + overlay.h = h + end + if not overlay.visible then + self:set_show_overlay(overlay, true) + end +end + + +local function get_split_sizes(split_type, x, y, w, h) + if split_type == "left" then + w = w * .5 + elseif split_type == "right" then + x = x + w * .5 + w = w * .5 + elseif split_type == "up" then + h = h * .5 + elseif split_type == "down" then + y = y + h * .5 + h = h * .5 + end + return x, y, w, h +end + + +function RootView:update_drag_overlay() + if not (self.dragged_node and self.dragged_node.dragging) then return end + local over = self.root_node:get_child_overlapping_point(self.mouse.x, self.mouse.y) + if over and not over.locked then + local _, _, _, tab_h = over:get_scroll_button_rect(1) + local x, y = over.position.x, over.position.y + local w, h = over.size.x, over.size.y + local split_type = over:get_split_type(self.mouse.x, self.mouse.y) + + 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) + self:set_show_overlay(self.drag_overlay, false) + self.drag_overlay_tab.last_over = over + else + if (over ~= self.dragged_node.node or #over.views > 1) then + y = y + tab_h + h = h - tab_h + x, y, w, h = get_split_sizes(split_type, x, y, w, h) + end + self:set_drag_overlay(self.drag_overlay, x, y, w, h) + self:set_show_overlay(self.drag_overlay_tab, false) + end + else + self:set_show_overlay(self.drag_overlay, false) + self:set_show_overlay(self.drag_overlay_tab, false) + end +end + + +function RootView:draw_grabbed_tab() + local dn = self.dragged_node + local _,_, w, h = dn.node:get_tab_rect(dn.idx) + local x = self.mouse.x - w / 2 + local y = self.mouse.y - h / 2 + local text = dn.node.views[dn.idx] and dn.node.views[dn.idx]:get_name() or "" + self.root_node:draw_tab(text, true, true, false, x, y, w, h, true) +end + + +function RootView:draw_drag_overlay(ov) + if ov.opacity > 0 then + renderer.draw_rect(ov.x, ov.y, ov.w, ov.h, ov.color) + end end @@ -922,6 +1142,12 @@ function RootView:draw() local t = table.remove(self.deferred_draws) t.fn(table.unpack(t)) end + + self:draw_drag_overlay(self.drag_overlay) + self:draw_drag_overlay(self.drag_overlay_tab) + if self.dragged_node and self.dragged_node.dragging then + self:draw_grabbed_tab() + end if core.cursor_change_req then system.set_cursor(core.cursor_change_req) core.cursor_change_req = nil diff --git a/data/core/style.lua b/data/core/style.lua index faca166e..9a6efb50 100644 --- a/data/core/style.lua +++ b/data/core/style.lua @@ -44,6 +44,8 @@ style.scrollbar2 = { common.color "#4b4b52" } style.nagbar = { common.color "#FF0000" } style.nagbar_text = { common.color "#FFFFFF" } style.nagbar_dim = { common.color "rgba(0, 0, 0, 0.45)" } +style.drag_overlay = { common.color "rgba(255,255,255,0.1)" } +style.drag_overlay_tab = { common.color "#93DDFA" } style.syntax = {} style.syntax["normal"] = { common.color "#e1e1e6" } From d81794417087b4d136775a4e487a4047036438ed Mon Sep 17 00:00:00 2001 From: Guldoman Date: Fri, 17 Sep 2021 02:48:20 +0200 Subject: [PATCH 03/72] Force showing tabs when dragging them --- data/core/rootview.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/data/core/rootview.lua b/data/core/rootview.lua index bf438aa5..0d219474 100644 --- a/data/core/rootview.lua +++ b/data/core/rootview.lua @@ -280,7 +280,10 @@ end function Node:should_show_tabs() if self.locked then return false end - if #self.views > 1 then return true + local dn = core.root_view.dragged_node + if #self.views > 1 + or (dn and dn.dragging) then -- show tabs while dragging + return true elseif config.always_show_tabs then return not self.views[1]:is(EmptyView) end From 0ff0ee2c617c612bac1da6743c2a3f0bf0f101e5 Mon Sep 17 00:00:00 2001 From: Francesco Date: Fri, 17 Sep 2021 22:38:09 +0200 Subject: [PATCH 04/72] Fix numpad fn keys (#532) * Fix the numeric keypad function keys As suggested in: https://github.com/lite-xl/lite-xl/issues/64 * Apply scancode lookup to KEY_UP events --- src/api/system.c | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/api/system.c b/src/api/system.c index 2f1bf763..f69de477 100644 --- a/src/api/system.c +++ b/src/api/system.c @@ -26,14 +26,11 @@ static const char* button_name(int button) { } -static char* key_name(char *dst, int sym) { - strcpy(dst, SDL_GetKeyName(sym)); - char *p = dst; +static void str_tolower(char *p) { while (*p) { *p = tolower(*p); p++; } - return dst; } struct HitTestInfo { @@ -93,6 +90,23 @@ static SDL_HitTestResult SDLCALL hit_test(SDL_Window *window, const SDL_Point *p return SDL_HITTEST_NORMAL; } +static const char *numpad[] = { "end", "down", "pagedown", "left", "", "right", "home", "up", "pageup", "ins", "delete" }; + +static const char *get_key_name(const SDL_Event *e, char *buf) { + SDL_Scancode scancode = e->key.keysym.scancode; + /* Is the scancode from the keypad and the number-lock off? + ** We assume that SDL_SCANCODE_KP_1 up to SDL_SCANCODE_KP_9 and SDL_SCANCODE_KP_0 + ** and SDL_SCANCODE_KP_PERIOD are declared in SDL2 in that order. */ + if (scancode >= SDL_SCANCODE_KP_1 && scancode <= SDL_SCANCODE_KP_1 + 10 && + !(KMOD_NUM & SDL_GetModState())) { + return numpad[scancode - SDL_SCANCODE_KP_1]; + } else { + strcpy(buf, SDL_GetKeyName(e->key.keysym.sym)); + str_tolower(buf); + return buf; + } +} + static int f_poll_event(lua_State *L) { char buf[16]; int mx, my, wx, wy; @@ -162,7 +176,7 @@ top: } #endif lua_pushstring(L, "keypressed"); - lua_pushstring(L, key_name(buf, e.key.keysym.sym)); + lua_pushstring(L, get_key_name(&e, buf)); return 2; case SDL_KEYUP: @@ -176,7 +190,7 @@ top: } #endif lua_pushstring(L, "keyreleased"); - lua_pushstring(L, key_name(buf, e.key.keysym.sym)); + lua_pushstring(L, get_key_name(&e, buf)); return 2; case SDL_TEXTINPUT: From e2f7c984de6d23a53e24a8dfaed21843edd161e9 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Fri, 17 Sep 2021 23:41:14 +0200 Subject: [PATCH 05/72] Reset syntax highlighting on file rename --- data/plugins/treeview.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/data/plugins/treeview.lua b/data/plugins/treeview.lua index 84d5dd28..26481398 100644 --- a/data/plugins/treeview.lua +++ b/data/plugins/treeview.lua @@ -453,6 +453,7 @@ command.add(function() return view.hovered_item ~= nil end, { for _, doc in ipairs(core.docs) do if doc.abs_filename and old_abs_filename == doc.abs_filename then doc:set_filename(filename, abs_filename) -- make doc point to the new filename + doc:reset_syntax() break -- only first needed end end From ab73f914add4d829d5c90f35fce35a7d38276990 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 18 Sep 2021 15:56:23 -0400 Subject: [PATCH 06/72] Added in custom runtime environment variable for ease of testing. (#538) --- src/main.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.c b/src/main.c index 470f0b5e..67d260b2 100644 --- a/src/main.c +++ b/src/main.c @@ -155,7 +155,7 @@ init_lua: " local exedir = EXEFILE:match('^(.*)" LITE_PATHSEP_PATTERN LITE_NONPATHSEP_PATTERN "$')\n" " local prefix = exedir:match('^(.*)" LITE_PATHSEP_PATTERN "bin$')\n" " dofile((MACOS_RESOURCES or (prefix and prefix .. '/share/lite-xl' or exedir .. '/data')) .. '/core/start.lua')\n" - " core = require('core')\n" + " core = require(os.getenv('LITE_XL_RUNTIME') or 'core')\n" " core.init()\n" " core.run()\n" "end, function(err)\n" From 48475c70a09d42cfcfc2350c0855b3a43e15163c Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Sun, 19 Sep 2021 18:42:36 +0200 Subject: [PATCH 07/72] Avoid unnecessary call to SDL_GetModState --- src/api/system.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/system.c b/src/api/system.c index f69de477..d84f86dd 100644 --- a/src/api/system.c +++ b/src/api/system.c @@ -98,7 +98,7 @@ static const char *get_key_name(const SDL_Event *e, char *buf) { ** We assume that SDL_SCANCODE_KP_1 up to SDL_SCANCODE_KP_9 and SDL_SCANCODE_KP_0 ** and SDL_SCANCODE_KP_PERIOD are declared in SDL2 in that order. */ if (scancode >= SDL_SCANCODE_KP_1 && scancode <= SDL_SCANCODE_KP_1 + 10 && - !(KMOD_NUM & SDL_GetModState())) { + !(e->key.keysym.mod & KMOD_NUM)) { return numpad[scancode - SDL_SCANCODE_KP_1]; } else { strcpy(buf, SDL_GetKeyName(e->key.keysym.sym)); From d067cc8577af7023f52dee4c62c44eeb37c1fca5 Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Sun, 19 Sep 2021 18:51:44 +0200 Subject: [PATCH 08/72] Scale custom syntax fonts for scale plugin Close #539. --- data/plugins/scale.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/data/plugins/scale.lua b/data/plugins/scale.lua index 8d16304b..acf3c7bb 100644 --- a/data/plugins/scale.lua +++ b/data/plugins/scale.lua @@ -56,6 +56,10 @@ local function set_scale(scale) renderer.font.set_size(style.code_font, s * style.code_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) From 34983668d89328bbee94f52d5d86f5ab3043b844 Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Sun, 19 Sep 2021 23:50:50 +0200 Subject: [PATCH 09/72] Normalize to project dir in treeview open When left-clicking in a TreeView file we use now core.normalize_to_project_dir to normalize correctly the file name. --- data/plugins/treeview.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/plugins/treeview.lua b/data/plugins/treeview.lua index 26481398..fa3ab53a 100644 --- a/data/plugins/treeview.lua +++ b/data/plugins/treeview.lua @@ -243,7 +243,7 @@ function TreeView:on_mouse_pressed(button, x, y, clicks) end else core.try(function() - local doc_filename = common.relative_path(core.project_dir, hovered_item.abs_filename) + local doc_filename = core.normalize_to_project_dir(hovered_item.abs_filename) core.root_view:open_doc(core.open_doc(doc_filename)) end) end From 291616df3f3dad86fdc724dfa470cd67675c5b48 Mon Sep 17 00:00:00 2001 From: Adam Harrison Date: Mon, 20 Sep 2021 23:50:06 -0400 Subject: [PATCH 10/72] Removed extra macros, used PLATFORM. Also removed MACOS, as it's redundant C code that's already encapsulated within PLATFORM. --- data/core/keymap.lua | 2 +- data/core/start.lua | 1 + src/main.c | 2 -- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/data/core/keymap.lua b/data/core/keymap.lua index 50eadec6..7f7c0fe2 100644 --- a/data/core/keymap.lua +++ b/data/core/keymap.lua @@ -5,7 +5,7 @@ keymap.modkeys = {} keymap.map = {} keymap.reverse_map = {} -local macos = rawget(_G, "MACOS") +local macos = PLATFORM == "Mac OS X" -- Thanks to mathewmariani, taken from his lite-macos github repository. local modkeys_os = require("core.modkeys-" .. (macos and "macos" or "generic")) diff --git a/data/core/start.lua b/data/core/start.lua index 71050057..24b6d978 100644 --- a/data/core/start.lua +++ b/data/core/start.lua @@ -19,3 +19,4 @@ package.path = DATADIR .. '/?.lua;' .. package.path package.path = DATADIR .. '/?/init.lua;' .. package.path package.path = USERDIR .. '/?.lua;' .. package.path package.path = USERDIR .. '/?/init.lua;' .. package.path + diff --git a/src/main.c b/src/main.c index 67d260b2..6275be74 100644 --- a/src/main.c +++ b/src/main.c @@ -140,8 +140,6 @@ init_lua: lua_setglobal(L, "EXEFILE"); #ifdef __APPLE__ - lua_pushboolean(L, true); - lua_setglobal(L, "MACOS"); enable_momentum_scroll(); #ifdef MACOS_USE_BUNDLE set_macos_bundle_resources(L); From ed3ea35ed5f9ba403cdba0f8e5c94c769fca41db Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 26 Sep 2021 10:18:13 -0400 Subject: [PATCH 11/72] Potentially fixing issue with cache not invalidating on restart. (#548) --- src/main.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.c b/src/main.c index 6275be74..a7147e76 100644 --- a/src/main.c +++ b/src/main.c @@ -184,6 +184,7 @@ init_lua: lua_pcall(L, 0, 1, 0); if (lua_toboolean(L, -1)) { lua_close(L); + rencache_invalidate(); goto init_lua; } From 6aa316e3c3498cac448773597c2cc9aa9b257986 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 26 Sep 2021 10:21:57 -0400 Subject: [PATCH 12/72] Rearranged DPI calc so that on calc failure, returns 1. (#547) --- src/main.c | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/main.c b/src/main.c index a7147e76..4eb1ec03 100644 --- a/src/main.c +++ b/src/main.c @@ -18,13 +18,12 @@ SDL_Window *window; static double get_scale(void) { -#ifdef __APPLE__ - return 1.0; -#else - float dpi = 96.0; - SDL_GetDisplayDPI(0, NULL, &dpi, NULL); - return dpi / 96.0; +#ifndef __APPLE__ + float dpi; + if (SDL_GetDisplayDPI(0, NULL, &dpi, NULL) == 0) + return dpi / 96.0; #endif + return 1.0; } From a97a3d80da737d47ac88bce331dff56cba14607e Mon Sep 17 00:00:00 2001 From: Not-a-web-Developer <47886897+Not-a-web-Developer@users.noreply.github.com> Date: Mon, 27 Sep 2021 19:29:25 +0530 Subject: [PATCH 13/72] fixed the build link in readme.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 73204c57..71f856a1 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ See the [licenses] file for details on licenses used by the required dependencie [screenshot-dark]: https://user-images.githubusercontent.com/433545/111063905-66943980-84b1-11eb-9040-3876f1133b20.png [lite]: https://github.com/rxi/lite [website]: https://lite-xl.github.io -[build]: https://lite-xl.github.io/en/build +[build]: https://lite-xl.github.io/en/documentation/build/ [Get Lite XL]: https://github.com/franko/lite-xl/releases/latest [Get plugins]: https://github.com/franko/lite-plugins [Get color themes]: https://github.com/rxi/lite-colors From 0b4d1e2bce99ba1ebae3722c183dd3b1d644f7fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Andr=C3=A9=20Santoni?= Date: Thu, 30 Sep 2021 03:22:00 +0700 Subject: [PATCH 14/72] Fix the size and blurriness of the icon on OSX (#553) * Fix the size and blurriness of the icon on OSX * Don't nest ifndef * Fix --- resources/icons/icon.icns | Bin 37249 -> 44037 bytes src/main.c | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/icons/icon.icns b/resources/icons/icon.icns index 128045d82aaa1c8a01ec0df88e95b851ed9cb259..71fea32f04f3bbc83d1101c8c046eb7b9d3031dd 100644 GIT binary patch literal 44037 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 Twx>Yvu$Kq+OZs#1^?mZc#fzXH literal 37249 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 Date: Thu, 30 Sep 2021 19:38:32 +0800 Subject: [PATCH 15/72] Remove duplicate command declaration (#565) --- data/core/commands/doc.lua | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/data/core/commands/doc.lua b/data/core/commands/doc.lua index fb17e674..b8ce2cb5 100644 --- a/data/core/commands/doc.lua +++ b/data/core/commands/doc.lua @@ -168,16 +168,6 @@ local commands = { doc():set_selection(line, col) end, - - ["doc:indent"] = function() - for idx, line1, col1, line2, col2 in doc_multiline_selections(true) do - local l1, c1, l2, c2 = doc():indent_text(false, line1, col1, line2, col2) - if l1 then - doc():set_selections(idx, l1, c1, l2, c2) - end - end - end, - ["doc:select-lines"] = function() for idx, line1, _, line2 in doc():get_selections(true) do append_line_if_last_line(line2) From f6b9d9ab671fcd7e8df4f69ff8fc1034c66e57d6 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Thu, 30 Sep 2021 22:10:38 +0200 Subject: [PATCH 16/72] Add option to disable scrolling past the end (#566) --- data/core/config.lua | 1 + data/core/docview.lua | 3 +++ 2 files changed, 4 insertions(+) diff --git a/data/core/config.lua b/data/core/config.lua index caecdfcd..f11c8959 100644 --- a/data/core/config.lua +++ b/data/core/config.lua @@ -5,6 +5,7 @@ config.fps = 60 config.max_log_items = 80 config.message_timeout = 5 config.mouse_wheel_scroll = 50 * SCALE +config.scroll_past_end = true config.file_size_limit = 10 config.ignore_files = "^%." config.symbol_pattern = "[%a_][%w_]*" diff --git a/data/core/docview.lua b/data/core/docview.lua index 161eac47..c26393a1 100644 --- a/data/core/docview.lua +++ b/data/core/docview.lua @@ -98,6 +98,9 @@ end function DocView:get_scrollable_size() + if not config.scroll_past_end then + return self:get_line_height() * (#self.doc.lines) + style.padding.y * 2 + end return self:get_line_height() * (#self.doc.lines - 1) + self.size.y end From 468229e4d0ffef92c506e7f56ce72d9f866db8a3 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Sat, 2 Oct 2021 03:24:35 +0200 Subject: [PATCH 17/72] Small cleanup of `scale` plugin --- data/plugins/scale.lua | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/data/plugins/scale.lua b/data/plugins/scale.lua index acf3c7bb..37d4cac9 100644 --- a/data/plugins/scale.lua +++ b/data/plugins/scale.lua @@ -13,9 +13,6 @@ config.plugins.scale = { use_mousewheel = true } -local MINIMUM_SCALE = 0.25; - -local scale_level = 0 local scale_steps = 0.05 local current_scale = SCALE @@ -36,9 +33,6 @@ local function set_scale(scale) local s = scale / current_scale current_scale = scale - -- we set scale_level in case this was called by user - scale_level = (scale - default_scale) / scale_steps - if config.plugins.scale.mode == "ui" then SCALE = scale @@ -85,18 +79,15 @@ function RootView:on_mouse_wheel(d, ...) end local function res_scale() - scale_level = 0 set_scale(default_scale) end local function inc_scale() - scale_level = scale_level + 1 - set_scale(default_scale + scale_level * scale_steps) + set_scale(current_scale + scale_steps) end -local function dec_scale() - scale_level = scale_level - 1 - set_scale(math.max(default_scale + scale_level * scale_steps), MINIMUM_SCALE) +local function dec_scale() + set_scale(current_scale - scale_steps) end From 57bfb67f6aa7d4db61ce1382b3489dd3baf9f5bd Mon Sep 17 00:00:00 2001 From: Guldoman Date: Sat, 2 Oct 2021 16:38:10 +0200 Subject: [PATCH 18/72] Add option to disable caret blinking (#572) --- data/core/config.lua | 1 + data/core/docview.lua | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/data/core/config.lua b/data/core/config.lua index f11c8959..eb4dcdf6 100644 --- a/data/core/config.lua +++ b/data/core/config.lua @@ -24,6 +24,7 @@ config.max_project_files = 2000 config.transitions = true config.animation_rate = 1.0 config.blink_period = 0.8 +config.disable_blink = false config.draw_whitespace = false config.borderless = false config.tab_close_button = true diff --git a/data/core/docview.lua b/data/core/docview.lua index c26393a1..9a2972dc 100644 --- a/data/core/docview.lua +++ b/data/core/docview.lua @@ -411,10 +411,12 @@ function DocView:draw_overlay() local T = config.blink_period for _, line, col in self.doc:get_selections() do if line >= minline and line <= maxline - and (core.blink_timer - core.blink_start) % T < T / 2 and system.window_has_focus() then - local x, y = self:get_line_screen_position(line) - self:draw_caret(x + self:get_col_x_offset(line, col), y) + if config.disable_blink + or (core.blink_timer - core.blink_start) % T < T / 2 then + local x, y = self:get_line_screen_position(line) + self:draw_caret(x + self:get_col_x_offset(line, col), y) + end end end end From 20ddbd6e9f04690afd85be1c619452e626c74602 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Sat, 2 Oct 2021 16:38:45 +0200 Subject: [PATCH 19/72] Load project module on project change (#571) --- data/core/init.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/data/core/init.lua b/data/core/init.lua index af291767..7ab4629d 100644 --- a/data/core/init.lua +++ b/data/core/init.lua @@ -79,6 +79,9 @@ function core.open_folder_project(dir_path_abs) if core.set_project_dir(dir_path_abs, core.on_quit_project) then core.root_view:close_all_docviews() update_recents_project("add", dir_path_abs) + if not core.load_project_module() then + command.perform("core:open-log") + end core.on_enter_project(dir_path_abs) end end From 72c950338c9b393a178ceb9ef31cc2ed3004a5f9 Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Sat, 2 Oct 2021 18:44:05 +0200 Subject: [PATCH 20/72] Enable always show tabs by default --- data/core/config.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/core/config.lua b/data/core/config.lua index eb4dcdf6..36d35c0a 100644 --- a/data/core/config.lua +++ b/data/core/config.lua @@ -12,8 +12,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 From 358903157945a028afdee4d22a429b4be4a1e7d5 Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Sat, 2 Oct 2021 18:44:27 +0200 Subject: [PATCH 21/72] Bump version number --- meson.build | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meson.build b/meson.build index 047ce90e..41736101 100644 --- a/meson.build +++ b/meson.build @@ -1,6 +1,6 @@ project('lite-xl', ['c', 'cpp'], - version : '2.0.2', + version : '2.0.3', license : 'MIT', meson_version : '>= 0.54', default_options : ['c_std=gnu11', 'cpp_std=c++03'] From db3643653eb79befc9334ac355c17fce6552b860 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Sat, 2 Oct 2021 20:01:23 +0200 Subject: [PATCH 22/72] Sanitize selections after redo --- data/core/doc/init.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/data/core/doc/init.lua b/data/core/doc/init.lua index aff31e94..26af4732 100644 --- a/data/core/doc/init.lua +++ b/data/core/doc/init.lua @@ -301,7 +301,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") From 7a435a568a1a1bf6a526f2e61f1195ecc07b3213 Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Thu, 7 Oct 2021 19:03:16 +0200 Subject: [PATCH 23/72] Fix error in incremental syntax highlighter In the highlither thread We should accept a previously generated line tokenization past first_invalid_line only if the text is the same. The text can change because of insert or remove operations. Close #573. --- data/core/doc/highlighter.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/core/doc/highlighter.lua b/data/core/doc/highlighter.lua index e7650d01..72fba793 100644 --- a/data/core/doc/highlighter.lua +++ b/data/core/doc/highlighter.lua @@ -24,7 +24,7 @@ function Highlighter:new(doc) for i = self.first_invalid_line, max do local state = (i > 1) and self.lines[i - 1].state local line = self.lines[i] - if not (line and line.init_state == state) then + if not (line and line.init_state == state and line.text == self.doc.lines[i]) then self.lines[i] = self:tokenize_line(i, state) end end From 92362586df7a2aa4451ef05e3812eef83468e985 Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Thu, 7 Oct 2021 19:19:03 +0200 Subject: [PATCH 24/72] Improve highlither for document edits The syntax highlighter keep a cache of the documents like tokenization. In order to minimize the amount of tokenize re-computations we insert some emtty lines or remove some lines in the highlither lines corresponding to the lines added or removed to the document. --- data/core/doc/highlighter.lua | 15 ++++++++++++++- data/core/doc/init.lua | 4 ++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/data/core/doc/highlighter.lua b/data/core/doc/highlighter.lua index 72fba793..4cb703da 100644 --- a/data/core/doc/highlighter.lua +++ b/data/core/doc/highlighter.lua @@ -44,12 +44,25 @@ function Highlighter:reset() self.max_wanted_line = 0 end - function Highlighter:invalidate(idx) self.first_invalid_line = math.min(self.first_invalid_line, idx) self.max_wanted_line = math.min(self.max_wanted_line, #self.doc.lines) end +function Highlighter:insert_notify(line, n) + self:invalidate(line) + for i = 1, n do + table.insert(self.lines, line, nil) + end +end + +function Highlighter:remove_notify(line, n) + self:invalidate(line) + for i = 1, n do + table.remove(self.lines, line) + end +end + function Highlighter:tokenize_line(idx, state) local res = {} diff --git a/data/core/doc/init.lua b/data/core/doc/init.lua index 26af4732..2e72907a 100644 --- a/data/core/doc/init.lua +++ b/data/core/doc/init.lua @@ -349,7 +349,7 @@ function Doc:raw_insert(line, col, text, undo_stack, time) push_undo(undo_stack, time, "remove", line, col, line2, col2) -- update highlighter and assure selection is in bounds - self.highlighter:invalidate(line) + self.highlighter:insert_notify(line, #lines - 1) self:sanitize_selection() end @@ -376,7 +376,7 @@ function Doc:raw_remove(line1, col1, line2, col2, undo_stack, time) end -- update highlighter and assure selection is in bounds - self.highlighter:invalidate(line1) + self.highlighter:remove_notify(line1, line2 - line1) self:sanitize_selection() end From 9c43727ebc269cb0695f6d418e5bf88615677aaf Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Mon, 12 Jul 2021 18:21:27 +0200 Subject: [PATCH 25/72] Implement directory monitoring using septag/dmon Use a notification based directory monitoring based on the septag/dmon lirbary instead of periodically rescan the whole project's tree. --- data/core/commands/core.lua | 6 +- data/core/config.lua | 1 - data/core/init.lua | 476 ++++++-- data/plugins/autoreload.lua | 5 +- data/plugins/treeview.lua | 46 +- licenses/licenses.md | 27 + meson.build | 3 +- resources/notes-dmon-integration.md | 54 + src/api/system.c | 100 ++ src/dirmonitor.c | 60 + src/dirmonitor.h | 14 + src/dmon.h | 1706 +++++++++++++++++++++++++++ src/main.c | 5 + src/meson.build | 1 + 14 files changed, 2333 insertions(+), 171 deletions(-) create mode 100644 resources/notes-dmon-integration.md create mode 100644 src/dirmonitor.c create mode 100644 src/dirmonitor.h create mode 100644 src/dmon.h diff --git a/data/core/commands/core.lua b/data/core/commands/core.lua index 432ded89..e836ea2f 100644 --- a/data/core/commands/core.lua +++ b/data/core/commands/core.lua @@ -66,8 +66,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 @@ -191,8 +191,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/config.lua b/data/core/config.lua index 36d35c0a..3de48ee1 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 diff --git a/data/core/init.lua b/data/core/init.lua index 7ab4629d..9ddf68e3 100644 --- a/data/core/init.lua +++ b/data/core/init.lua @@ -52,13 +52,6 @@ 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 @@ -66,9 +59,6 @@ function core.set_project_dir(new_dir, change_project_fn) core.project_dir = common.normalize_path(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,20 @@ 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(info.filename, config.ignore_files) + and info) + end +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 +114,27 @@ 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, begin_hook, max_files) if begin_hook then begin_hook() end - local size_limit = config.file_size_limit * 10e5 local all = system.list_dir(root .. path) or {} 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 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 (not max_files or entries_count <= max_files) and core.project_subdir_is_shown(dir, f.filename) then + local sub_limit = max_files and max_files - entries_count + local _, n = get_directory_files(dir, root, PATHSEP .. f.filename, t, begin_hook, sub_limit) + entries_count = entries_count + n end end @@ -149,132 +146,289 @@ local function get_directory_files(root, path, t, recursive, begin_hook) return t, 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() + 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 + +-- Populate a project folder top directory by scanning the filesystem. +local function scan_project_folder(index) + local dir = core.project_directories[index] + local t, entries_count = get_directory_files(dir, dir.name, "", {}, nil, config.max_project_files) + if entries_count > config.max_project_files then + dir.files_limit = true + -- 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") + if core.status_view then -- May be not yet initialized. + show_max_files_warning() + end + else + dir.watch_id = system.watch_dir(dir.name, true) + end + dir.files = t + core.dir_rescan_add_job(dir, ".") +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) + 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 + + +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 + repeat + if files[inf].filename == filename then + return inf, true + end + inf = inf + 1 + until inf > sup or system.path_compare(filename, type, files[inf].filename, files[inf].type) + 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, {}, 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 + + +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, {}) 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 +525,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 @@ -519,7 +660,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() @@ -530,6 +670,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() + end + for _, filename in ipairs(files) do core.root_view:open_doc(core.open_doc(filename)) end @@ -918,6 +1064,76 @@ 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 + + +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) + end +end + function core.on_event(type, ...) local did_keymap = false @@ -954,6 +1170,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 @@ -1060,7 +1278,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/plugins/autoreload.lua b/data/plugins/autoreload.lua index e772666f..55a2d99e 100644 --- a/data/plugins/autoreload.lua +++ b/data/plugins/autoreload.lua @@ -3,9 +3,10 @@ local core = require "core" local config = require "core.config" local Doc = require "core.doc" - local times = setmetatable({}, { __mode = "k" }) +local autoreload_scan_rate = 5 + local function update_time(doc) local info = system.get_file_info(doc.filename) times[doc] = info.modified @@ -40,7 +41,7 @@ core.add_thread(function() end -- wait for next scan - coroutine.yield(config.project_scan_rate) + coroutine.yield(autoreload_scan_rate) end end) diff --git a/data/plugins/treeview.lua b/data/plugins/treeview.lua index fa3ab53a..d08db03e 100644 --- a/data/plugins/treeview.lua +++ b/data/plugins/treeview.lua @@ -41,7 +41,6 @@ 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 } end @@ -54,7 +53,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 +79,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 +104,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 +126,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 +201,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,23 +217,11 @@ 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() @@ -461,7 +443,6 @@ 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, @@ -476,7 +457,6 @@ 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, @@ -489,7 +469,6 @@ command.add(function() return view.hovered_item ~= nil 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 +505,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/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 41736101..f1d2b354 100644 --- a/meson.build +++ b/meson.build @@ -45,6 +45,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'] ) @@ -57,7 +58,7 @@ if not get_option('source-only') ] ) - lite_deps = [lua_dep, sdl_dep, reproc_dep, pcre2_dep, libm, libdl] + lite_deps = [lua_dep, sdl_dep, reproc_dep, pcre2_dep, libm, libdl, threads_dep] if host_machine.system() == 'windows' # Note that we need to explicitly add the windows socket DLL because 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/src/api/system.c b/src/api/system.c index d84f86dd..5b72b4d8 100644 --- a/src/api/system.c +++ b/src/api/system.c @@ -6,6 +6,7 @@ #include #include #include "api.h" +#include "dirmonitor.h" #include "rencache.h" #ifdef _WIN32 #include @@ -236,6 +237,14 @@ top: lua_pushnumber(L, e.wheel.y); return 2; + case SDL_USEREVENT: + lua_pushstring(L, "dirchange"); + lua_pushnumber(L, e.user.code >> 16); + lua_pushstring(L, (e.user.code & 0xffff) == DMON_ACTION_DELETE ? "delete" : "create"); + lua_pushstring(L, e.user.data1); + free(e.user.data1); + return 4; + default: goto top; } @@ -651,6 +660,91 @@ static int f_set_window_opacity(lua_State *L) { return 1; } +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 }, @@ -678,6 +772,12 @@ static const luaL_Reg lib[] = { { "exec", f_exec }, { "fuzzy_match", f_fuzzy_match }, { "set_window_opacity", f_set_window_opacity }, + { "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 }, +#endif { NULL, NULL } }; diff --git a/src/dirmonitor.c b/src/dirmonitor.c new file mode 100644 index 00000000..eb3b185f --- /dev/null +++ b/src/dirmonitor.c @@ -0,0 +1,60 @@ +#include +#include + +#include + +#define DMON_IMPL +#include "dmon.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; + case DMON_ACTION_MODIFY: + 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..ab9376c0 --- /dev/null +++ b/src/dirmonitor.h @@ -0,0 +1,14 @@ +#ifndef DIRMONITOR_H +#define DIRMONITOR_H + +#include + +#include "dmon.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/dmon.h b/src/dmon.h new file mode 100644 index 00000000..1ccae446 --- /dev/null +++ b/src/dmon.h @@ -0,0 +1,1706 @@ +// +// 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 +// +#ifndef __DMON_H__ +#define __DMON_H__ + +#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); +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 + +#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_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_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]); + + for (int j = i; j < c - 1; j++) { + memcpy(watch->subdirs + j, watch->subdirs + j + 1, sizeof(dmon__watch_subdir)); + memcpy(watch->wds + j, watch->wds + j + 1, sizeof(int)); + } + stb__sbraw(watch->subdirs)[1] = c - 1; + stb__sbraw(watch->wds)[1] = c - 1; + + if (!skip_lock) + pthread_mutex_unlock(&_dmon.mutex); + return true; +} + +_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); + _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); + + // 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/src/main.c b/src/main.c index 182511d0..e0fcb107 100644 --- a/src/main.c +++ b/src/main.c @@ -14,6 +14,8 @@ #include #endif +#include "dirmonitor.h" + SDL_Window *window; @@ -107,6 +109,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); @@ -189,6 +193,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 707e04e9..2da04fda 100644 --- a/src/meson.build +++ b/src/meson.build @@ -6,6 +6,7 @@ lite_sources = [ 'api/regex.c', 'api/system.c', 'api/process.c', + 'dirmonitor.c', 'renderer.c', 'renwindow.c', 'fontdesc.c', From bba42adc73bf592b9de4aa4a5d44410618ec59ae Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Fri, 8 Oct 2021 21:55:43 +0200 Subject: [PATCH 26/72] Adopt new version of dmon --- src/dirmonitor.c | 1 + src/dirmonitor.h | 1 + src/dmon.h | 130 ++----------------------------------- src/dmon_extra.h | 162 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 124 deletions(-) create mode 100644 src/dmon_extra.h diff --git a/src/dirmonitor.c b/src/dirmonitor.c index eb3b185f..958d463e 100644 --- a/src/dirmonitor.c +++ b/src/dirmonitor.c @@ -5,6 +5,7 @@ #define DMON_IMPL #include "dmon.h" +#include "dmon_extra.h" #include "dirmonitor.h" diff --git a/src/dirmonitor.h b/src/dirmonitor.h index ab9376c0..074a9ae8 100644 --- a/src/dirmonitor.h +++ b/src/dirmonitor.h @@ -4,6 +4,7 @@ #include #include "dmon.h" +#include "dmon_extra.h" void dirmonitor_init(); void dirmonitor_deinit(); diff --git a/src/dmon.h b/src/dmon.h index 1ccae446..ed10d11f 100644 --- a/src/dmon.h +++ b/src/dmon.h @@ -1,3 +1,6 @@ +#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 @@ -68,9 +71,9 @@ // 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 // -#ifndef __DMON_H__ -#define __DMON_H__ #include #include @@ -114,8 +117,6 @@ DMON_API_DECL dmon_watch_id dmon_watch(const char* rootdir, const char* oldfilepath, void* user), uint32_t flags, void* user_data); DMON_API_DECL void dmon_unwatch(dmon_watch_id id); -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 } @@ -322,6 +323,7 @@ _DMON_PRIVATE char* dmon__strcat(char* dst, int dst_sz, const char* src) // 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]) @@ -763,126 +765,6 @@ _DMON_PRIVATE void dmon__watch_recursive(const char* dirname, int fd, uint32_t m closedir(dir); } -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]); - - for (int j = i; j < c - 1; j++) { - memcpy(watch->subdirs + j, watch->subdirs + j + 1, sizeof(dmon__watch_subdir)); - memcpy(watch->wds + j, watch->wds + j + 1, sizeof(int)); - } - stb__sbraw(watch->subdirs)[1] = c - 1; - stb__sbraw(watch->wds)[1] = c - 1; - - if (!skip_lock) - pthread_mutex_unlock(&_dmon.mutex); - return true; -} - _DMON_PRIVATE const char* dmon__find_subdir(const dmon__watch_state* watch, int wd) { const int* wds = watch->wds; diff --git a/src/dmon_extra.h b/src/dmon_extra.h new file mode 100644 index 00000000..4b321034 --- /dev/null +++ b/src/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__ + From a9f6f01ed03594ff25b8af99f094785b35b4065d Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Fri, 8 Oct 2021 22:10:17 +0200 Subject: [PATCH 27/72] Move dmon files into lib/dmon --- {src => lib/dmon}/dmon.h | 0 {src => lib/dmon}/dmon_extra.h | 0 lib/font_renderer/meson.build | 2 +- meson.build | 5 ++--- src/meson.build | 4 ++-- 5 files changed, 5 insertions(+), 6 deletions(-) rename {src => lib/dmon}/dmon.h (100%) rename {src => lib/dmon}/dmon_extra.h (100%) diff --git a/src/dmon.h b/lib/dmon/dmon.h similarity index 100% rename from src/dmon.h rename to lib/dmon/dmon.h diff --git a/src/dmon_extra.h b/lib/dmon/dmon_extra.h similarity index 100% rename from src/dmon_extra.h rename to lib/dmon/dmon_extra.h diff --git a/lib/font_renderer/meson.build b/lib/font_renderer/meson.build index d596e152..569de826 100644 --- a/lib/font_renderer/meson.build +++ b/lib/font_renderer/meson.build @@ -9,7 +9,7 @@ font_renderer_sources = [ font_renderer_cdefs = ['-DFONT_RENDERER_HEIGHT_HACK'] -font_renderer_include = include_directories('.') +lite_includes += include_directories('.') libfontrenderer = static_library('fontrenderer', font_renderer_sources, diff --git a/meson.build b/meson.build index f1d2b354..613204b7 100644 --- a/meson.build +++ b/meson.build @@ -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' @@ -118,11 +119,9 @@ configure_file( install_dir : lite_datadir / 'core', ) -#=============================================================================== -# Targets -#=============================================================================== if not get_option('source-only') subdir('lib/font_renderer') + subdir('lib/dmon') subdir('src') subdir('scripts') endif diff --git a/src/meson.build b/src/meson.build index 2da04fda..503fdc48 100644 --- a/src/meson.build +++ b/src/meson.build @@ -22,11 +22,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, font_renderer_include], + include_directories: lite_includes, dependencies: lite_deps, c_args: lite_cargs, objc_args: lite_cargs, From 911a3cee08c32cf7a2d17146494a9eff48073f64 Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Fri, 8 Oct 2021 23:13:50 +0200 Subject: [PATCH 28/72] Report dmon modify events --- data/core/init.lua | 7 +++++++ src/api/system.c | 14 +++++++++++++- src/dirmonitor.c | 2 -- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/data/core/init.lua b/data/core/init.lua index 9ddf68e3..d964b624 100644 --- a/data/core/init.lua +++ b/data/core/init.lua @@ -1123,6 +1123,10 @@ function core.dir_rescan_add_job(dir, filepath) end +function core.on_dirmonitor_modify() +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 @@ -1131,6 +1135,9 @@ function core.on_dir_change(watch_id, action, filepath) 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 diff --git a/src/api/system.c b/src/api/system.c index 5b72b4d8..c1a4abd3 100644 --- a/src/api/system.c +++ b/src/api/system.c @@ -240,7 +240,19 @@ top: case SDL_USEREVENT: lua_pushstring(L, "dirchange"); lua_pushnumber(L, e.user.code >> 16); - lua_pushstring(L, (e.user.code & 0xffff) == DMON_ACTION_DELETE ? "delete" : "create"); + 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; diff --git a/src/dirmonitor.c b/src/dirmonitor.c index 958d463e..0063e400 100644 --- a/src/dirmonitor.c +++ b/src/dirmonitor.c @@ -52,8 +52,6 @@ void dirmonitor_watch_callback(dmon_watch_id watch_id, dmon_action action, const send_sdl_event(watch_id, DMON_ACTION_DELETE, oldfilepath); send_sdl_event(watch_id, DMON_ACTION_CREATE, filepath); break; - case DMON_ACTION_MODIFY: - break; default: send_sdl_event(watch_id, action, filepath); } From 7dd5699c96f852c41204c927c3a8d5d9410fc100 Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Fri, 8 Oct 2021 23:15:25 +0200 Subject: [PATCH 29/72] Use dmon events in reload plugin --- data/core/init.lua | 1 + data/plugins/autoreload.lua | 27 ++++++++++----------------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/data/core/init.lua b/data/core/init.lua index d964b624..9de283ad 100644 --- a/data/core/init.lua +++ b/data/core/init.lua @@ -1123,6 +1123,7 @@ function core.dir_rescan_add_job(dir, filepath) end +-- no-op but can be overrided by plugins function core.on_dirmonitor_modify() end diff --git a/data/plugins/autoreload.lua b/data/plugins/autoreload.lua index 55a2d99e..9978092e 100644 --- a/data/plugins/autoreload.lua +++ b/data/plugins/autoreload.lua @@ -5,14 +5,11 @@ local Doc = require "core.doc" local times = setmetatable({}, { __mode = "k" }) -local autoreload_scan_rate = 5 - local function update_time(doc) local info = system.get_file_info(doc.filename) times[doc] = info.modified end - local function reload_doc(doc) local fp = io.open(doc.filename, "r") local text = fp:read("*a") @@ -28,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(autoreload_scan_rate) end -end) - + on_modify(dir, filepath) +end -- patch `Doc.save|load` to store modified time local load = Doc.load From a99dd947ed85aa86ca29d416a7bd07bdce4dee84 Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Sat, 9 Oct 2021 14:37:33 +0200 Subject: [PATCH 30/72] Add missing meson file --- lib/dmon/meson.build | 1 + 1 file changed, 1 insertion(+) create mode 100644 lib/dmon/meson.build 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('.') From 56eace627a0702da5713240b8e5e2544cb6e9aa7 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Fri, 10 Sep 2021 21:32:34 +0200 Subject: [PATCH 31/72] Add reverse option to `search.find` --- data/core/doc/search.lua | 71 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/data/core/doc/search.lua b/data/core/doc/search.lua index 04090673..7ff2dca7 100644 --- a/data/core/doc/search.lua +++ b/data/core/doc/search.lua @@ -23,6 +23,45 @@ local function init_args(doc, line, col, text, opt) end +local function plain_rfind(text, pattern, index) + local len = #text + text = text:reverse() + pattern = pattern:reverse() + if index >= 0 then + index = len - index + 1 + else + index = index * -1 + end + local s, e = text:find(pattern, index, true) + return e and len - e + 1, s and len - s + 1 +end + + +local function rfind(text, pattern, index, plain) + if plain then return plain_rfind(text, pattern, index) end + local s, e = text:find(pattern) + 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 = text:find(pattern, s + 1) + end + return last_s, last_e +end + + +local function rcmatch(re, text, index) + local s, e = re:cmatch(text) + local last_s, last_e + if index < 0 then index = #text - index + 1 end + while e and e <= index + 1 do + last_s, last_e = s, e + s, e = re:cmatch(text, s + 1) + 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) @@ -30,29 +69,47 @@ function search.find(doc, line, col, text, opt) if opt.regex then re = regex.compile(text, opt.no_case and "i" or "") 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) + local s, e + if opt.reverse then + s, e = rcmatch(re, line_text, col - 1) + else + s, e = re:cmatch(line_text, col) + end if s then return line, s, line, e end - col = 1 + col = opt.reverse and -1 or 1 else if opt.no_case then line_text = line_text:lower() end - local s, e = line_text:find(text, col, true) + local s, e + if opt.reverse then + s, e = rfind(line_text, text, col - 1, true) + else + s, e = line_text:find(text, col, true) + end if s then return line, s, line, e + 1 end - col = 1 + col = opt.reverse and -1 or 1 end 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 From e7be9652c96a99a5e218bdfd84dc06290ca176bf Mon Sep 17 00:00:00 2001 From: Guldoman Date: Fri, 10 Sep 2021 21:54:50 +0200 Subject: [PATCH 32/72] Add `find-replace:select-previous` --- data/core/commands/findreplace.lua | 25 ++++++++++++++++--------- data/core/keymap.lua | 1 + 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/data/core/commands/findreplace.lua b/data/core/commands/findreplace.lua index f8e8e45a..e1153fe5 100644 --- a/data/core/commands/findreplace.lua +++ b/data/core/commands/findreplace.lua @@ -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,15 +161,22 @@ 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", { diff --git a/data/core/keymap.lua b/data/core/keymap.lua index 7f7c0fe2..0c2dd863 100644 --- a/data/core/keymap.lua +++ b/data/core/keymap.lua @@ -170,6 +170,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", From 1976facaf1a2b9934f3cfdb4524712c1418000ef Mon Sep 17 00:00:00 2001 From: Guldoman Date: Fri, 10 Sep 2021 21:58:11 +0200 Subject: [PATCH 33/72] Use reverse search for `find-replace:previous-find` --- data/core/commands/findreplace.lua | 39 +++++++++++++++--------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/data/core/commands/findreplace.lua b/data/core/commands/findreplace.lua index e1153fe5..cff023d2 100644 --- a/data/core/commands/findreplace.lua +++ b/data/core/commands/findreplace.lua @@ -7,8 +7,7 @@ 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 @@ -53,8 +52,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 @@ -181,8 +180,8 @@ command.add(has_unique_selection, { 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, @@ -228,29 +227,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, }) From af925d603bc7632ff87918ad1ad080032fb3c0a5 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Sat, 11 Sep 2021 03:37:12 +0200 Subject: [PATCH 34/72] Fix `doc` selection in `findreplace` Use `last_view` if `active_view` is `CommandView`. --- data/core/commands/findreplace.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/data/core/commands/findreplace.lua b/data/core/commands/findreplace.lua index cff023d2..67aa90d5 100644 --- a/data/core/commands/findreplace.lua +++ b/data/core/commands/findreplace.lua @@ -14,7 +14,8 @@ 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.doc end local function get_find_tooltip() From cfe0c79a0415ab9089f0cb6f3c1d421ce29b87fd Mon Sep 17 00:00:00 2001 From: Guldoman Date: Sat, 9 Oct 2021 00:24:48 +0200 Subject: [PATCH 35/72] Simplify reverse search Remove `plain_rfind` optimization. --- data/core/doc/search.lua | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/data/core/doc/search.lua b/data/core/doc/search.lua index 7ff2dca7..babf9cc8 100644 --- a/data/core/doc/search.lua +++ b/data/core/doc/search.lua @@ -23,28 +23,13 @@ local function init_args(doc, line, col, text, opt) end -local function plain_rfind(text, pattern, index) - local len = #text - text = text:reverse() - pattern = pattern:reverse() - if index >= 0 then - index = len - index + 1 - else - index = index * -1 - end - local s, e = text:find(pattern, index, true) - return e and len - e + 1, s and len - s + 1 -end - - local function rfind(text, pattern, index, plain) - if plain then return plain_rfind(text, pattern, index) end - local s, e = text:find(pattern) + local s, e = text:find(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 = text:find(pattern, s + 1) + s, e = text:find(pattern, s + 1, plain) end return last_s, last_e end From 3a1274fd08dfc3d6a9c3a0e659e3bb856ed791c0 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Sun, 10 Oct 2021 00:42:30 +0200 Subject: [PATCH 36/72] Merge reverse find functions for lua patterns and regexes --- data/core/doc/search.lua | 68 +++++++++++++++------------------------- 1 file changed, 25 insertions(+), 43 deletions(-) diff --git a/data/core/doc/search.lua b/data/core/doc/search.lua index babf9cc8..b4c553c9 100644 --- a/data/core/doc/search.lua +++ b/data/core/doc/search.lua @@ -22,26 +22,20 @@ 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(text, pattern, index, plain) - local s, e = text:find(pattern, 1, plain) +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 = text:find(pattern, s + 1, plain) - end - return last_s, last_e -end - - -local function rcmatch(re, text, index) - local s, e = re:cmatch(text) - local last_s, last_e - if index < 0 then index = #text - index + 1 end - while e and e <= index + 1 do - last_s, last_e = s, e - s, e = re:cmatch(text, s + 1) + s, e = func(text, pattern, s + 1, plain) end return last_s, last_e end @@ -49,10 +43,11 @@ end function search.find(doc, line, col, text, opt) doc, line, col, text, opt = init_args(doc, line, col, text, opt) - - local re + 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 local start, finish, step = line, #doc.lines, 1 if opt.reverse then @@ -60,32 +55,19 @@ function search.find(doc, line, col, text, opt) end for line = start, finish, step do local line_text = doc.lines[line] - if opt.regex then - local s, e - if opt.reverse then - s, e = rcmatch(re, line_text, col - 1) - else - s, e = re:cmatch(line_text, col) - end - if s then - return line, s, line, e - end - col = opt.reverse and -1 or 1 - else - if opt.no_case then - line_text = line_text:lower() - end - local s, e - if opt.reverse then - s, e = rfind(line_text, text, col - 1, true) - else - s, e = line_text:find(text, col, true) - end - if s then - return line, s, line, e + 1 - end - col = opt.reverse and -1 or 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) + else + s, e = search_func(line_text, pattern, col) + end + if s then + return line, s, line, e + 1 + end + col = opt.reverse and -1 or 1 end if opt.wrap then From cb08c5cbb7e3679fa90d40d784d223fa197aeb5a Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Sun, 10 Oct 2021 14:52:55 +0200 Subject: [PATCH 37/72] Fix dirty pixels problem on window's right side The last column of pixel on the window's right side isn't correctly drawn and pixels appear dirty and more noticeably when the a NagView message was previously shown, a stripe of red pixels remains on the right. We use now a more souding roundig scheme. Now the rectangles to clip or to draw are passed around as Lua numbers without any rounding. In turns, when the rect coordinates are passed to the renderer we ensure the border of the rect are correctly snapped to the pixel's grid. It works by computing the coordinates of the edges, round them to integers and then compute the rect's width based on the rounded coordinates values. --- data/core/rootview.lua | 2 +- data/core/view.lua | 2 +- src/api/renderer.c | 27 +++++++++++++++++---------- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/data/core/rootview.lua b/data/core/rootview.lua index 0d219474..fa200aec 100644 --- a/data/core/rootview.lua +++ b/data/core/rootview.lua @@ -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, pos.x + size.x, pos.y + size.y) self.active_view:draw() core.pop_clip_rect() else diff --git a/data/core/view.lua b/data/core/view.lua index d1374ee4..5b4b3228 100644 --- a/data/core/view.lua +++ b/data/core/view.lua @@ -140,7 +140,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/src/api/renderer.c b/src/api/renderer.c index 8dc13ada..ec3bc3ba 100644 --- a/src/api/renderer.c +++ b/src/api/renderer.c @@ -49,23 +49,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; From 0f8d7f32022723c8fde16060a3bd9fe81f14c339 Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Sun, 10 Oct 2021 14:58:51 +0200 Subject: [PATCH 38/72] Do no add rencache a command for empty rectangles --- src/rencache.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/rencache.c b/src/rencache.c index 31165e90..5a64a5ea 100644 --- a/src/rencache.c +++ b/src/rencache.c @@ -160,7 +160,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; From c7aa3ebe0116b08d16c50e0262f00f8c3dd54601 Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Sun, 10 Oct 2021 21:44:16 +0200 Subject: [PATCH 39/72] Fix clipping error in docview --- data/core/docview.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/core/docview.lua b/data/core/docview.lua index 9a2972dc..7dbfbccf 100644 --- a/data/core/docview.lua +++ b/data/core/docview.lua @@ -439,7 +439,7 @@ 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) + 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 From 8b634daa669ac2cf814cc7ec018ef9ddd8c97ec9 Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Sun, 10 Oct 2021 21:48:16 +0200 Subject: [PATCH 40/72] Use rounded value for node's size when splitting Rouding node's size to an integer value ensure drawing are pixel perfect in sizing. --- data/core/rootview.lua | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/data/core/rootview.lua b/data/core/rootview.lua index fa200aec..3c4feda0 100644 --- a/data/core/rootview.lua +++ b/data/core/rootview.lua @@ -411,15 +411,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 = math.floor(x1 and x1 + ds or (x2 and self.size[x] - x2 or 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 From 0d2166c9cee35b8381a3467be2fb52a1e0d918fb Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Mon, 11 Oct 2021 09:25:38 +0200 Subject: [PATCH 41/72] Correct Node's clipping rectangle Fixing the Node's clipping rectangle make the clipping in DocView:draw() partially redundant. This latter is now no longer needed to clip on the right when drawing the document's lines but it still serves to the purpose of clipping on the left, before the gutter region. --- data/core/docview.lua | 2 ++ data/core/rootview.lua | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/data/core/docview.lua b/data/core/docview.lua index 7dbfbccf..a73bfc9c 100644 --- a/data/core/docview.lua +++ b/data/core/docview.lua @@ -439,6 +439,8 @@ function DocView:draw() local pos = self.position x, y = self:get_line_screen_position(minline) + -- 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) diff --git a/data/core/rootview.lua b/data/core/rootview.lua index 3c4feda0..f5b1e534 100644 --- a/data/core/rootview.lua +++ b/data/core/rootview.lua @@ -595,7 +595,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, pos.x + size.x, pos.y + size.y) + core.push_clip_rect(pos.x, pos.y, size.x, size.y) self.active_view:draw() core.pop_clip_rect() else From 3a71528087206cdd084e62424ad6539490fbf12d Mon Sep 17 00:00:00 2001 From: Guldoman Date: Mon, 11 Oct 2021 22:18:02 +0200 Subject: [PATCH 42/72] Allow specifying offset for `common.is_utf8_cont` --- data/core/common.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/core/common.lua b/data/core/common.lua index 1a1b22cd..0d360640 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 From 038e335c8c1813b257e132a30f00a4d60ee0b153 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Mon, 11 Oct 2021 22:20:44 +0200 Subject: [PATCH 43/72] Show error message when `pcre2_match` fails --- src/api/regex.c | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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); From 1872e8214137b02e2d7c905d267b3d76297d6087 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Mon, 11 Oct 2021 22:32:50 +0200 Subject: [PATCH 44/72] Make `regex.match` return the appropriate `end` index This makes its behavior similar to `string.find`. --- data/core/regex.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 8a516d35ce4aeecc9f7b2879028a89f9d8816d11 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Mon, 11 Oct 2021 22:37:31 +0200 Subject: [PATCH 45/72] Correctly identify the start of the next character in `tokenizer` When moving to the next character, we have to consider that the current one might be multi-byte. --- data/core/tokenizer.lua | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/data/core/tokenizer.lua b/data/core/tokenizer.lua index a20dba5e..08a5ea31 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 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 From ef60b24f632cad403f9c7de382dade8dd8f1a358 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Sat, 16 Oct 2021 02:56:01 +0200 Subject: [PATCH 46/72] Check both values returned by `Node:get_locked_size` --- data/core/commands/root.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/data/core/commands/root.lua b/data/core/commands/root.lua index e41c723d..49bf774a 100644 --- a/data/core/commands/root.lua +++ b/data/core/commands/root.lua @@ -112,7 +112,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 +121,6 @@ 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) From 780c8c6d0d7ac42e5e502fb7f6d5b40ef5a30837 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Sat, 16 Oct 2021 03:02:42 +0200 Subject: [PATCH 47/72] Improve check for `find-replace` commands using `has_unique_selection` --- data/core/commands/findreplace.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/core/commands/findreplace.lua b/data/core/commands/findreplace.lua index 67aa90d5..5d27aa69 100644 --- a/data/core/commands/findreplace.lua +++ b/data/core/commands/findreplace.lua @@ -15,7 +15,7 @@ local found_expression local function 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.doc + return is_DocView and core.active_view.doc or (last_view and last_view.doc) end local function get_find_tooltip() @@ -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 From f472c24c73289b59b2284c4b4b24b1fc7319ae49 Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Tue, 12 Oct 2021 14:28:28 +0200 Subject: [PATCH 48/72] First attempt to treat correctly network volumes On windows paths belonging to network volumes will be gives like: \\address\share-name\path Now the code recognize these paths and treat them correctly. --- data/core/common.lua | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/data/core/common.lua b/data/core/common.lua index 1a1b22cd..ebd3df40 100644 --- a/data/core/common.lua +++ b/data/core/common.lua @@ -282,22 +282,41 @@ 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, filepath = "/", 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 From 9c52c420c595f8e71053f6b715c888dd8d08734c Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Tue, 12 Oct 2021 16:08:06 +0200 Subject: [PATCH 49/72] Do not use normalize_path when not needed --- data/core/common.lua | 18 ++++++++++++++++++ data/core/init.lua | 12 ++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/data/core/common.lua b/data/core/common.lua index ebd3df40..3b1e7add 100644 --- a/data/core/common.lua +++ b/data/core/common.lua @@ -280,6 +280,24 @@ local function split_on_slash(s, sep_pattern) end +-- 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 diff --git a/data/core/init.lua b/data/core/init.lua index 9de283ad..eb3a49b9 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 @@ -56,7 +56,7 @@ 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) return true @@ -198,7 +198,7 @@ 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) + path = common.normalize_volume(path) local dir = { name = path, item = {filename = common.basename(path), type = "dir", topdir = true}, @@ -572,9 +572,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 From 43374b036f7be3c44399fabbd593b8a99304328f Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Tue, 12 Oct 2021 18:06:00 +0200 Subject: [PATCH 50/72] Fix problem with treeview x resizing The x size of the treeview plugin cannot really change expect if explicitly resized. The call to move_towards for x seems to raise a state where core.redraw is always set to true and this prevent the application to go idle. It is seen after the introduction of the dmon directory monitoring but it is not clear why it wasn't seen before. --- data/plugins/treeview.lua | 2 -- 1 file changed, 2 deletions(-) diff --git a/data/plugins/treeview.lua b/data/plugins/treeview.lua index d08db03e..5202cc78 100644 --- a/data/plugins/treeview.lua +++ b/data/plugins/treeview.lua @@ -238,8 +238,6 @@ function TreeView:update() if self.init_size then self.size.x = dest self.init_size = false - else - self:move_towards(self.size, "x", dest) end local duration = system.get_time() - self.tooltip.begin From f18ac849fb908add8b1558f555ad378a247e85d7 Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Tue, 12 Oct 2021 22:19:24 +0200 Subject: [PATCH 51/72] Fix error introduced with 43fc35d7 --- data/core/common.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/core/common.lua b/data/core/common.lua index 3b1e7add..99e6ee5a 100644 --- a/data/core/common.lua +++ b/data/core/common.lua @@ -315,7 +315,7 @@ function common.normalize_path(filename) else local relpath = filename:match('^/(.+)') if relpath then - volume, filepath = "/", relpath + volume, filename = "/", relpath end end local parts = split_on_slash(filename, PATHSEP) From 167e41de65f40b99a4a79194c0a92b587a76b18a Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Thu, 14 Oct 2021 09:13:06 +0200 Subject: [PATCH 52/72] Fix problem with treeview keeping the editor busy Fix a problem introduced when fixing the dirty pixel problem, commit cb08c5c. The node, when determining the layout was rounding the size of the fixed-size view. In turns this latter was calling move_towards to the default_size it wanted. If default_size was non-integer the value vas never archieved because it was rounded during layout and move_towars was keeping the editor busy by setting the core.need_redraw flag. --- data/core/rootview.lua | 6 +++++- data/plugins/treeview.lua | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/data/core/rootview.lua b/data/core/rootview.lua index f5b1e534..04a091c4 100644 --- a/data/core/rootview.lua +++ b/data/core/rootview.lua @@ -412,7 +412,7 @@ end -- 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 ds = ((x1 and x1 < 1) or (x2 and x2 < 1)) and 0 or style.divider_size - local n = math.floor(x1 and x1 + ds or (x2 and self.size[x] - x2 or self.size[x] * self.divider)) + 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 @@ -675,6 +675,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 diff --git a/data/plugins/treeview.lua b/data/plugins/treeview.lua index 5202cc78..d08db03e 100644 --- a/data/plugins/treeview.lua +++ b/data/plugins/treeview.lua @@ -238,6 +238,8 @@ function TreeView:update() if self.init_size then self.size.x = dest self.init_size = false + else + self:move_towards(self.size, "x", dest) end local duration = system.get_time() - self.tooltip.begin From e9c16c43674ecf912512905bc94802564419bee0 Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Thu, 21 Oct 2021 11:07:37 +0200 Subject: [PATCH 53/72] Add a limit for very slow filesystems When adding a directory in a project we check if the filesystem is too slow. If it is too slow we act as if the projects was files-limited by the number of files but we show a specific warning. This solution is not perfect but for very low filesystem it can limit the problem. Otherwise the application would be totally irresponsive. --- data/core/init.lua | 47 ++++++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/data/core/init.lua b/data/core/init.lua index eb3a49b9..3b8ee16d 100644 --- a/data/core/init.lua +++ b/data/core/init.lua @@ -106,6 +106,15 @@ local function get_project_file_info(root, file) 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. @@ -114,12 +123,13 @@ 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(dir, root, path, t, begin_hook, max_files) +local function get_directory_files(dir, root, path, t, entries_count, recurse_pred, begin_hook) if begin_hook then begin_hook() end + 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 for _, file in ipairs(all) do local info = get_project_file_info(root, path .. PATHSEP .. file) if info then @@ -128,13 +138,16 @@ local function get_directory_files(dir, root, path, t, begin_hook, max_files) end end + local recurse_complete = true table.sort(dirs, compare_file) for _, f in ipairs(dirs) do table.insert(t, f) - if (not max_files or entries_count <= max_files) and core.project_subdir_is_shown(dir, f.filename) then - local sub_limit = max_files and max_files - entries_count - local _, n = get_directory_files(dir, root, PATHSEP .. f.filename, t, begin_hook, sub_limit) - entries_count = entries_count + n + 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 @@ -143,7 +156,7 @@ local function get_directory_files(dir, root, path, t, begin_hook, max_files) table.insert(t, f) end - return t, entries_count + return t, recurse_complete, entries_count end @@ -165,26 +178,28 @@ function core.project_subdir_is_shown(dir, filename) end -local function show_max_files_warning() - core.status_view:show_message("!", style.accent, +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 -- Populate a project folder top directory by scanning the filesystem. local function scan_project_folder(index) local dir = core.project_directories[index] - local t, entries_count = get_directory_files(dir, dir.name, "", {}, nil, config.max_project_files) - if entries_count > config.max_project_files then + 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 -- 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") if core.status_view then -- May be not yet initialized. - show_max_files_warning() + show_max_files_warning(dir) end else dir.watch_id = system.watch_dir(dir.name, true) @@ -298,7 +313,7 @@ local function project_subdir_bounds(dir, filename) end local function rescan_project_subdir(dir, filename_rooted) - local new_files = get_directory_files(dir, dir.name, filename_rooted, {}, coroutine.yield) + 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) @@ -316,7 +331,7 @@ 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, {}) or {} + 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 @@ -673,7 +688,7 @@ function core.init() -- 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() + show_max_files_warning(core.project_directories[1]) end for _, filename in ipairs(files) do From 7bdfcd529e9950633f3117b690cd21431b1b6f9c Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Tue, 12 Oct 2021 22:08:17 +0200 Subject: [PATCH 54/72] Add a function to detect filesystem type on linux --- src/api/system.c | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/api/system.c b/src/api/system.c index c1a4abd3..c84f5fee 100644 --- a/src/api/system.c +++ b/src/api/system.c @@ -12,6 +12,8 @@ #include #include #include +#elseif __linux__ + #include #endif extern SDL_Window *window; @@ -545,6 +547,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); @@ -789,6 +830,7 @@ static const luaL_Reg lib[] = { #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 } }; From ddb6196e9e76920a07c3b5d9e8da30c370b05c6c Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Thu, 21 Oct 2021 23:57:17 +0200 Subject: [PATCH 55/72] Force project rescan on network filesystems --- data/core/init.lua | 109 ++++++++++++++++++++++++++++----------------- src/api/system.c | 2 +- 2 files changed, 68 insertions(+), 43 deletions(-) diff --git a/data/core/init.lua b/data/core/init.lua index 3b8ee16d..4a16e6b7 100644 --- a/data/core/init.lua +++ b/data/core/init.lua @@ -187,48 +187,6 @@ local function show_max_files_warning(dir) core.status_view:show_message("!", style.accent, message) end --- Populate a project folder top directory by scanning the filesystem. -local function scan_project_folder(index) - local dir = core.project_directories[index] - 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 - -- 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") - if core.status_view then -- May be not yet initialized. - show_max_files_warning(dir) - end - else - dir.watch_id = system.watch_dir(dir.name, true) - end - dir.files = t - core.dir_rescan_add_job(dir, ".") -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 - local function file_search(files, info) local filename, type = info.filename, info.type @@ -328,6 +286,73 @@ local function rescan_project_subdir(dir, filename_rooted) 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 diff --git a/src/api/system.c b/src/api/system.c index c84f5fee..0ebf78f1 100644 --- a/src/api/system.c +++ b/src/api/system.c @@ -12,7 +12,7 @@ #include #include #include -#elseif __linux__ +#elif __linux__ #include #endif From 331b8e90ec95a8342832d6f9e889928d510d5aa9 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Sat, 23 Oct 2021 03:34:24 +0200 Subject: [PATCH 56/72] Select a new primary node when closing the current one The new primary node can be any non-locked leaf node that isn't already primary. --- data/core/rootview.lua | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/data/core/rootview.lua b/data/core/rootview.lua index 04a091c4..51eedc10 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 @@ -823,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 From ffb66cefd77a4ad2c90e698df440d3f09d08feef Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Sat, 23 Oct 2021 15:01:16 +0200 Subject: [PATCH 57/72] Fix python docstring highlighting From PR: https://github.com/lite-xl/lite-xl/pull/624 contributed by @dflock. --- data/plugins/language_python.lua | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/data/plugins/language_python.lua b/data/plugins/language_python.lua index e19caa63..60aa41a6 100644 --- a/data/plugins/language_python.lua +++ b/data/plugins/language_python.lua @@ -6,16 +6,16 @@ syntax.add { 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", From 5cdd8009109409cc45daff7b5364e5d43da95ca0 Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Sat, 23 Oct 2021 15:03:09 +0200 Subject: [PATCH 58/72] Fix problem checking utf-8 cont at end of string --- data/core/tokenizer.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/core/tokenizer.lua b/data/core/tokenizer.lua index 08a5ea31..d95baeb1 100644 --- a/data/core/tokenizer.lua +++ b/data/core/tokenizer.lua @@ -145,7 +145,7 @@ function tokenizer.tokenize(incoming_syntax, text, state) repeat local next = res[2] + 1 -- go to the start of the next utf-8 character - while common.is_utf8_cont(text, next) do + 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) } From d41aed61c92ca24941d1f6820ec55cb52c988bab Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Sat, 23 Oct 2021 15:16:51 +0200 Subject: [PATCH 59/72] Update changelog --- changelog.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) 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. From 80d837b05b0713f9ce268f8c64b048537741220d Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Sat, 23 Oct 2021 15:46:30 +0200 Subject: [PATCH 60/72] Fix assert with dmon on directory deleting --- lib/dmon/dmon.h | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/dmon/dmon.h b/lib/dmon/dmon.h index ed10d11f..2bc9e0c3 100644 --- a/lib/dmon/dmon.h +++ b/lib/dmon/dmon.h @@ -927,8 +927,11 @@ _DMON_PRIVATE void dmon__inotify_process_events(void) 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); - _DMON_UNUSED(wd); - DMON_ASSERT(wd != -1); + // 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); From e68d6016f89a2e65846c2a1ac60179f849cdb5c3 Mon Sep 17 00:00:00 2001 From: Francesco Abbate Date: Sat, 23 Oct 2021 08:07:14 -0700 Subject: [PATCH 61/72] Add skip-subproject option in package script --- scripts/package.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 92db048e7c4bb02967156bc9fcd26744e746b097 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Mon, 25 Oct 2021 00:18:20 +0200 Subject: [PATCH 62/72] Use plain search by default in `search.find` --- data/core/doc/search.lua | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/data/core/doc/search.lua b/data/core/doc/search.lua index b4c553c9..8395769a 100644 --- a/data/core/doc/search.lua +++ b/data/core/doc/search.lua @@ -43,6 +43,7 @@ end function search.find(doc, line, col, text, opt) doc, line, col, text, opt = init_args(doc, line, col, text, opt) + local plain = not opt.pattern local pattern = text local search_func = string.find if opt.regex then @@ -60,9 +61,9 @@ function search.find(doc, line, col, text, opt) end local s, e if opt.reverse then - s, e = rfind(search_func, line_text, pattern, col - 1) + s, e = rfind(search_func, line_text, pattern, col - 1, plain) else - s, e = search_func(line_text, pattern, col) + s, e = search_func(line_text, pattern, col, plain) end if s then return line, s, line, e + 1 From df665ddc3842dbe4677c1cbbafe55b80670f3c1c Mon Sep 17 00:00:00 2001 From: Guldoman Date: Mon, 25 Oct 2021 14:06:07 +0200 Subject: [PATCH 63/72] Use `header` to get syntax only when provided --- data/core/syntax.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/core/syntax.lua b/data/core/syntax.lua index a763ac78..de8ec9d0 100644 --- a/data/core/syntax.lua +++ b/data/core/syntax.lua @@ -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 From 9e721937af962098386cff37803c7aeef4d99367 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Tue, 26 Oct 2021 00:12:16 +0200 Subject: [PATCH 64/72] Don't insert `nil` in highlighter lines table When `highlighter:insert_notify` was called, a hole in the array was created. If another call to `highlighter:insert_notify` happened before the hole was filled, a `Position out of bounds` error could have been raised. --- data/core/doc/highlighter.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/core/doc/highlighter.lua b/data/core/doc/highlighter.lua index 4cb703da..c77e1138 100644 --- a/data/core/doc/highlighter.lua +++ b/data/core/doc/highlighter.lua @@ -52,7 +52,7 @@ end function Highlighter:insert_notify(line, n) self:invalidate(line) for i = 1, n do - table.insert(self.lines, line, nil) + table.insert(self.lines, line, false) end end From f99afcd29c777544f1f3bc4f9d8b2802a5b15c5d Mon Sep 17 00:00:00 2001 From: Guldoman Date: Sun, 7 Nov 2021 23:12:03 +0100 Subject: [PATCH 65/72] In `TreeView` set correct active `View` before opening new `DocView` Before this, the new `DocView` was always added to the primary `Node`. With this, it gets added to the last focused `Node`. --- data/plugins/treeview.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/data/plugins/treeview.lua b/data/plugins/treeview.lua index d08db03e..77b6732f 100644 --- a/data/plugins/treeview.lua +++ b/data/plugins/treeview.lua @@ -225,6 +225,9 @@ function TreeView:on_mouse_pressed(button, x, y, clicks) 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) From 24669293c71a2a34ad9a4ba528ddcb45323350d3 Mon Sep 17 00:00:00 2001 From: Adam Harrison Date: Sun, 7 Nov 2021 17:54:42 -0500 Subject: [PATCH 66/72] Made it so that we originally start on the parent directory of the current project, but provide a list of recently used projects if on that directory. If a directory separator is added, then everything is as normal. --- data/core/commands/core.lua | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/data/core/commands/core.lua b/data/core/commands/core.lua index e836ea2f..2280f620 100644 --- a/data/core/commands/core.lua +++ b/data/core/commands/core.lua @@ -9,7 +9,8 @@ local fullscreen = 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, { @@ -149,7 +150,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 +167,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) From 5b8c08e93ae3cb79b4e0602e0cc74e90e6d695e5 Mon Sep 17 00:00:00 2001 From: Adam Harrison Date: Sun, 7 Nov 2021 17:57:15 -0500 Subject: [PATCH 67/72] Missing parentheses. --- data/core/commands/core.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/core/commands/core.lua b/data/core/commands/core.lua index 2280f620..62be4bc6 100644 --- a/data/core/commands/core.lua +++ b/data/core/commands/core.lua @@ -9,7 +9,7 @@ local fullscreen = false local function suggest_directory(text) text = common.home_expand(text) - return common.home_encode_list(text == "" or text == common.home_expand(common.dirname(core.project_dir)) + 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 From 6bc4fbb238f75546cf217e7aacedf2afa341ce47 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Tue, 9 Nov 2021 22:21:45 +0100 Subject: [PATCH 68/72] Restore `TitleView` only when needed Before, every time the user came back from fullscreen, the `TitleView` was shown regardless of its previous status. --- data/core/commands/core.lua | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/data/core/commands/core.lua b/data/core/commands/core.lua index 62be4bc6..ad0d4b10 100644 --- a/data/core/commands/core.lua +++ b/data/core/commands/core.lua @@ -6,6 +6,7 @@ local LogView = require "core.logview" local fullscreen = false +local restore_title_view = false local function suggest_directory(text) text = common.home_expand(text) @@ -28,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() From f24aa64cd5af8ab2ae07bf567c4ee91d4d7095c6 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Sat, 20 Nov 2021 01:15:13 +0100 Subject: [PATCH 69/72] Add `soft_reset` to highlighter This allows clearing the `lines` table without removing entries. --- data/core/doc/highlighter.lua | 7 +++++++ data/core/doc/init.lua | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/data/core/doc/highlighter.lua b/data/core/doc/highlighter.lua index c77e1138..68b66356 100644 --- a/data/core/doc/highlighter.lua +++ b/data/core/doc/highlighter.lua @@ -40,6 +40,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 diff --git a/data/core/doc/init.lua b/data/core/doc/init.lua index 2e72907a..b0f07921 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 From d0a2c913f505a6e357bb4edff127d00cd3834930 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Sat, 20 Nov 2021 01:18:37 +0100 Subject: [PATCH 70/72] Pre-populate the highlighter This avoids problems with calls to `[insert,remove]_notify` on lines that the highlighter has not yet added. --- data/core/doc/init.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/data/core/doc/init.lua b/data/core/doc/init.lua index b0f07921..06cde9f9 100644 --- a/data/core/doc/init.lua +++ b/data/core/doc/init.lua @@ -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") From 3176b467ca26d904dc2cac06f02e2394da57f9ba Mon Sep 17 00:00:00 2001 From: Guldoman Date: Sun, 21 Nov 2021 03:46:43 +0100 Subject: [PATCH 71/72] Add names to language plugins --- data/plugins/language_c.lua | 1 + data/plugins/language_cpp.lua | 1 + data/plugins/language_css.lua | 1 + data/plugins/language_html.lua | 1 + data/plugins/language_js.lua | 1 + data/plugins/language_lua.lua | 1 + data/plugins/language_md.lua | 1 + data/plugins/language_python.lua | 1 + data/plugins/language_xml.lua | 1 + 9 files changed, 9 insertions(+) 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..291c1287 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 = { 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..62cb8a86 100644 --- a/data/plugins/language_md.lua +++ b/data/plugins/language_md.lua @@ -4,6 +4,7 @@ local syntax = require "core.syntax" syntax.add { + name = "Markdown", files = { "%.md$", "%.markdown$" }, patterns = { { pattern = "\\.", type = "normal" }, diff --git a/data/plugins/language_python.lua b/data/plugins/language_python.lua index 60aa41a6..f1430fb1 100644 --- a/data/plugins/language_python.lua +++ b/data/plugins/language_python.lua @@ -2,6 +2,7 @@ local syntax = require "core.syntax" syntax.add { + name = "Python", files = { "%.py$", "%.pyw$" }, headers = "^#!.*[ /]python", comment = "#", 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 = { From 23a0f6ca796651e122f3e921aea35f6e495e3f65 Mon Sep 17 00:00:00 2001 From: Guldoman Date: Mon, 22 Nov 2021 06:23:16 +0100 Subject: [PATCH 72/72] Speed up highlighter notify Avoid calling `table.{insert,remove}` multiple times, as this causes multiple shifts in the `self.lines` table. --- data/core/doc/highlighter.lua | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/data/core/doc/highlighter.lua b/data/core/doc/highlighter.lua index c77e1138..22ed12a4 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" @@ -51,16 +52,16 @@ end function Highlighter:insert_notify(line, n) self:invalidate(line) + local blanks = { } for i = 1, n do - table.insert(self.lines, line, false) + 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