-- mod-version:3 local core = require "core" local common = require "core.common" local command = require "core.command" local config = require "core.config" local keymap = require "core.keymap" local style = require "core.style" local View = require "core.view" local CommandView = require "core.commandview" local DocView = require "core.docview" local TodoTreeView = View:extend() local SCOPES = { ALL = "all", FOCUSED = "focused", } config.plugins.todotreeview = common.merge({ todo_tags = {"TODO", "BUG", "FIX", "FIXME", "IMPROVEMENT"}, tag_colors = { TODO = {tag=style.text, tag_hover=style.accent, text=style.text, text_hover=style.accent}, BUG = {tag=style.text, tag_hover=style.accent, text=style.text, text_hover=style.accent}, FIX = {tag=style.text, tag_hover=style.accent, text=style.text, text_hover=style.accent}, FIXME = {tag=style.text, tag_hover=style.accent, text=style.text, text_hover=style.accent}, IMPROVEMENT = {tag=style.text, tag_hover=style.accent, text=style.text, text_hover=style.accent}, }, todo_file_color = { name=style.text, hover=style.accent }, -- Paths or files to be ignored ignore_paths = {}, -- Tells if the plugin should start with the nodes expanded todo_expanded = true, -- 'tag' mode can be used to group the todos by tags -- 'file' mode can be used to group the todos by files -- 'file_tag' mode can be used to group the todos by files and then by tags inside the files todo_mode = "tag", treeview_size = 200 * SCALE, -- default size -- Only used in file mode when the tag and the text are on the same line todo_separator = " - ", -- Text displayed when the note is empty todo_default_text = "blank", -- Scope of the displayed tags -- 'all' scope to show all tags from the project all the time -- 'focused' scope to show the tags from the currently focused file todo_scope = SCOPES.ALL, -- The config specification used by the settings gui config_spec = { name = "TodoTreeView", { label = "Todo Tags", description = "List of tags to parse and show in the todo panel.", path = "todo_tags", type = "list_strings", default = {"TODO", "BUG", "FIX", "FIXME", "IMPROVEMENT"}, }, { label = "Paths to ignore", description = "Paths to be ignored when parsing the tags.", path = "ignore_paths", type = "list_strings", default = {} }, { label = "Groups Expanded", description = "Defines if the groups (Tags / Files) should start expanded.", path = "todo_expanded", type = "toggle", default = true, }, { label = "Mode for the todos", description = "Mode for the todos to be displayed.", path = "todo_mode", type = "selection", default = "tag", values = { {"Tag", "tag"}, {"File", "file"}, {"FileTag", "file_tag"} } }, { label = "Treeview Size", description = "Size of the todo tree view panel.", path = "treeview_size", type = "number", default = 200 * SCALE, }, { label = "Todo separator", description = "Separator used in file mode when the tag and the text are on the same line.", path = "todo_separator", type = "string", default = " - ", }, { label = "Empty Default Text", description = "Default text displayed for a note when it has no text.", path = "todo_default_text", type = "string", default = " - ", }, { label = "Todo Scope", description = "Scope for the notes to be picked, all notes or currently focused file.", path = "todo_scope", type = "selection", default = "all", values = { {"All", "all"}, {"Focused", "focused"}, } }, } }, config.plugins.todotreeview) local icon_small_font = style.icon_font:copy(10 * SCALE) function TodoTreeView:new() TodoTreeView.super.new(self) self.scrollable = true self.focusable = false self.visible = true self.times_cache = {} self.cache = {} self.cache_updated = false self.init_size = true self.focus_index = 0 self.filter = "" self.previous_focused_file = nil -- Items are generated from cache according to the mode self.items = {} end local function is_file_ignored(filename) for _, path in ipairs(config.plugins.todotreeview.ignore_paths) do local s, _ = filename:find(path) if s then return true end end return false end function TodoTreeView:is_file_in_scope(filename) if config.plugins.todotreeview.todo_scope == SCOPES.ALL then return true elseif config.plugins.todotreeview.todo_scope == SCOPES.FOCUSED then if core.active_view:is(CommandView) or core.active_view:is(TodoTreeView) then if self.previous_focused_file then return self.previous_focused_file == filename end elseif core.active_view:is(DocView) then return core.active_view.doc.filename == filename end return true else assert(false, "Unknown scope defined ("..config.plugins.todotreeview.todo_scope..")") end end function TodoTreeView.get_all_files() local all_files = {} for _, file in ipairs(core.project_files) do if file.filename then all_files[file.filename] = file end end for _, file in ipairs(core.docs) do if file.filename and not all_files[file.filename] then all_files[file.filename] = { filename = file.filename, type = "file" } end end return all_files end function TodoTreeView:refresh_cache() local items = {} if not next(self.items) then items = self.items end self.updating_cache = true core.add_thread(function() for _, item in pairs(self.get_all_files()) do local ignored = is_file_ignored(item.filename) if not ignored and item.type == "file" then local cached = self:get_cached(item) if config.plugins.todotreeview.todo_mode == "file" then items[cached.filename] = cached elseif config.plugins.todotreeview.todo_mode == "file_tag" then local file_t = {} file_t.expanded = config.plugins.todotreeview.todo_expanded file_t.type = "file" file_t.tags = {} file_t.todos = {} file_t.filename = cached.filename file_t.abs_filename = cached.abs_filename items[cached.filename] = file_t for _, todo in ipairs(cached.todos) do local tag = todo.tag if not file_t.tags[tag] then local tag_t = {} tag_t.expanded = config.plugins.todotreeview.todo_expanded tag_t.type = "group" tag_t.todos = {} tag_t.tag = tag file_t.tags[tag] = tag_t end table.insert(file_t.tags[tag].todos, todo) end else for _, todo in ipairs(cached.todos) do local tag = todo.tag if not items[tag] then local t = {} t.expanded = config.plugins.todotreeview.todo_expanded t.type = "group" t.todos = {} t.tag = tag items[tag] = t end table.insert(items[tag].todos, todo) end end end end -- Copy expanded from old items if config.plugins.todotreeview.todo_mode == "tag" and next(self.items) then for tag, data in pairs(self.items) do if items[tag] then items[tag].expanded = data.expanded end end end self.items = items core.redraw = true self.cache_updated = true self.updating_cache = false end, self) end local function find_file_todos(t, filename) local fp = io.open(filename) if not fp then return t end local n = 1 for line in fp:lines() do for _, todo_tag in ipairs(config.plugins.todotreeview.todo_tags) do -- Add spaces at the start and end of line so the pattern will pick -- tags at the start and at the end of lines local extended_line = " "..line.." " local match_str = "[^a-zA-Z_\"'`]"..todo_tag.."[^\"'a-zA-Z_`]+" local s, e = extended_line:find(match_str) if s then local d = {} d.type = "todo" d.tag = todo_tag d.filename = filename d.text = extended_line:sub(e+1) if d.text == "" then d.text = config.plugins.todotreeview.todo_default_text end d.line = n d.col = s table.insert(t, d) end core.redraw = true end if n % 100 == 0 then coroutine.yield() end n = n + 1 core.redraw = true end fp:close() end function TodoTreeView:get_cached(item) local t = self.cache[item.filename] if not t then t = {} t.expanded = config.plugins.todotreeview.todo_expanded t.filename = item.filename t.abs_filename = system.absolute_path(item.filename) t.type = item.type t.todos = {} t.tags = {} find_file_todos(t.todos, t.filename) self.cache[t.filename] = t end return t end function TodoTreeView:get_name() return "Todo Tree" end function TodoTreeView:set_target_size(axis, value) if axis == "x" then config.plugins.todotreeview.treeview_size = value return true end end function TodoTreeView:get_item_height() return style.font:get_height() + style.padding.y end function TodoTreeView:get_cached_time(doc) local t = self.times_cache[doc] if not t then local info = system.get_file_info(doc.filename) if not info then return nil end self.times_cache[doc] = info.modified end return t end function TodoTreeView:check_cache() local existing_docs = {} for _, doc in ipairs(core.docs) do if doc.filename then existing_docs[doc.filename] = true local info = system.get_file_info(doc.filename) local cached = self:get_cached_time(doc) if not info and cached then -- document deleted self.times_cache[doc] = nil self.cache[doc.filename] = nil self.cache_updated = false elseif cached and cached ~= info.modified then -- document modified self.times_cache[doc] = info.modified self.cache[doc.filename] = nil self.cache_updated = false elseif not cached then self.cache_updated = false end end end for _, file in ipairs(core.project_files) do existing_docs[file.filename] = true end -- Check for docs in cache that may not exist anymore -- for example: (Openend from outside of project and closed) for filename, doc in pairs(self.cache) do local exists = existing_docs[filename] if not exists then self.times_cache[doc] = nil self.cache[filename] = nil self.cache_updated = false end end if core.project_files ~= self.last_project_files then self.last_project_files = core.project_files self.cache_updated = false end end function TodoTreeView:each_item() self:check_cache() if not self.updating_cache and not self.cache_updated then self:refresh_cache() end return coroutine.wrap(function() local ox, oy = self:get_content_offset() local y = oy + style.padding.y local w = self.size.x local h = self:get_item_height() for filename, item in pairs(self.items) do local in_scope = item.type == "group" or self:is_file_in_scope(item.filename) if in_scope and #item.todos > 0 then coroutine.yield(item, ox, y, w, h) y = y + h for _, todo in ipairs(item.todos) do if item.expanded then local in_todo = string.find(todo.text:lower(), self.filter:lower()) local todo_in_scope = self:is_file_in_scope(todo.filename) if todo_in_scope and (#self.filter == 0 or in_todo) then coroutine.yield(todo, ox, y, w, h) y = y + h end end end end if in_scope and item.tags then local first_tag = true for _, tag in pairs(item.tags) do if first_tag then coroutine.yield(item, ox, y, w, h) y = y + h first_tag = false end if item.expanded then coroutine.yield(tag, ox, y, w, h) y = y + h for _, todo in ipairs(tag.todos) do if item.expanded and tag.expanded then local in_todo = string.find(todo.text:lower(), self.filter:lower()) local todo_in_scope = self:is_file_in_scope(todo.filename) if todo_in_scope and (#self.filter == 0 or in_todo) then coroutine.yield(todo, ox, y, w, h) y = y + h end end end end end end end end) end function TodoTreeView:on_mouse_moved(px, py) self.hovered_item = nil for item, x,y,w,h in self:each_item() do if px > x and py > y and px <= x + w and py <= y + h then self.hovered_item = item break end end end function TodoTreeView:goto_hovered_item() if not self.hovered_item then return end if self.hovered_item.type == "group" or self.hovered_item.type == "file" then return end core.try(function() local i = self.hovered_item local dv = core.root_view:open_doc(core.open_doc(i.filename)) core.root_view.root_node:update_layout() dv.doc:set_selection(i.line, i.col) dv:scroll_to_line(i.line, false, true) end) end function TodoTreeView:on_mouse_pressed(button, x, y) if not self.hovered_item then return elseif self.hovered_item.type == "file" or self.hovered_item.type == "group" then self.hovered_item.expanded = not self.hovered_item.expanded else self:goto_hovered_item() end end function TodoTreeView:update() -- Update focus if core.active_view:is(DocView) then self.previous_focused_file = core.active_view.doc.filename elseif core.active_view:is(CommandView) or core.active_view:is(TodoTreeView) then -- Do nothing else self.previous_focused_file = nil end self.scroll.to.y = math.max(0, self.scroll.to.y) -- update width local dest = self.visible and config.plugins.todotreeview.treeview_size or 0 if self.init_size then self.size.x = dest self.init_size = false else self:move_towards(self.size, "x", dest) end TodoTreeView.super.update(self) end function TodoTreeView:draw() self:draw_background(style.background2) --local h = self:get_item_height() local icon_width = style.icon_font:get_width("D") local spacing = style.font:get_width(" ") * 2 local root_depth = 0 for item, x,y,w,h in self:each_item() do local text_color = style.text local tag_color = style.text local file_color = config.plugins.todotreeview.todo_file_color.name or style.text if config.plugins.todotreeview.tag_colors[item.tag] then text_color = config.plugins.todotreeview.tag_colors[item.tag].text or style.text tag_color = config.plugins.todotreeview.tag_colors[item.tag].tag or style.text end -- hovered item background if item == self.hovered_item then renderer.draw_rect(x, y, w, h, style.line_highlight) text_color = style.accent tag_color = style.accent file_color = config.plugins.todotreeview.todo_file_color.hover or style.accent if config.plugins.todotreeview.tag_colors[item.tag] then text_color = config.plugins.todotreeview.tag_colors[item.tag].text_hover or style.accent tag_color = config.plugins.todotreeview.tag_colors[item.tag].tag_hover or style.accent end end -- icons local item_depth = 0 x = x + (item_depth - root_depth) * style.padding.x + style.padding.x if item.type == "file" then local icon1 = item.expanded and "-" or "+" common.draw_text(style.icon_font, file_color, icon1, nil, x, y, 0, h) x = x + style.padding.x common.draw_text(style.icon_font, file_color, "f", nil, x, y, 0, h) x = x + icon_width elseif item.type == "group" then if config.plugins.todotreeview.todo_mode == "file_tag" then x = x + style.padding.x * 0.75 end if item.expanded then common.draw_text(style.icon_font, tag_color, "-", nil, x, y, 0, h) else common.draw_text(icon_small_font, tag_color, ">", nil, x, y, 0, h) end x = x + icon_width / 2 else if config.plugins.todotreeview.todo_mode == "tag" then x = x + style.padding.x else x = x + style.padding.x * 1.5 end common.draw_text(style.icon_font, text_color, "i", nil, x, y, 0, h) x = x + icon_width end -- text x = x + spacing if item.type == "file" then common.draw_text(style.font, file_color, item.filename, nil, x, y, 0, h) elseif item.type == "group" then common.draw_text(style.font, tag_color, item.tag, nil, x, y, 0, h) else if config.plugins.todotreeview.todo_mode == "file" then common.draw_text(style.font, tag_color, item.tag, nil, x, y, 0, h) x = x + style.font:get_width(item.tag) common.draw_text(style.font, text_color, config.plugins.todotreeview.todo_separator..item.text, nil, x, y, 0, h) else common.draw_text(style.font, text_color, item.text, nil, x, y, 0, h) end end end end function TodoTreeView:get_item_by_index(index) local i = 0 for item in self:each_item() do if index == i then return item end i = i + 1 end return nil end function TodoTreeView:get_hovered_parent_file_tag() local file_parent = nil local file_parent_index = 0 local group_parent = nil local group_parent_index = 0 local i = 0 for item in self:each_item() do if item.type == "file" then file_parent = item file_parent_index = i end if item.type == "group" then group_parent = item group_parent_index = i end if i == self.focus_index then if item.type == "file" or item.type == "group" then return file_parent, file_parent_index else return group_parent, group_parent_index end end i = i + 1 end return nil, 0 end function TodoTreeView:get_hovered_parent() local parent = nil local parent_index = 0 local i = 0 for item in self:each_item() do if item.type == "group" or item.type == "file" then parent = item parent_index = i end if i == self.focus_index then return parent, parent_index end i = i + 1 end return nil, 0 end function TodoTreeView:update_scroll_position() local h = self:get_item_height() local _, min_y, _, max_y = self:get_content_bounds() local start_row = math.floor(min_y / h) local end_row = math.floor(max_y / h) if self.focus_index < start_row then self.scroll.to.y = self.focus_index * h end if self.focus_index + 1 > end_row then self.scroll.to.y = (self.focus_index * h) - self.size.y + h end end -- init local view = TodoTreeView() local node = core.root_view:get_active_node() view.size.x = config.plugins.todotreeview.treeview_size node:split("right", view, {x=true}, true) core.status_view:add_item({ predicate = function() return #view.filter > 0 and core.active_view and not core.active_view:is(CommandView) end, name = "todotreeview:filter", alignment = core.status_view.Item.RIGHT, get_item = function() return { style.text, string.format("Filter: %s", view.filter) } end, position = 1, tooltip = "Todos filtered by", separator = core.status_view.separator2 }) -- register commands and keymap local previous_view = nil command.add(nil, { ["todotreeview:toggle"] = function() view.visible = not view.visible end, ["todotreeview:expand-items"] = function() for _, item in pairs(view.items) do item.expanded = true end end, ["todotreeview:hide-items"] = function() for _, item in pairs(view.items) do item.expanded = false end end, ["todotreeview:toggle-focus"] = function() if not core.active_view:is(TodoTreeView) then previous_view = core.active_view core.set_active_view(view) view.hovered_item = view:get_item_by_index(view.focus_index) else command.perform("todotreeview:release-focus") end end, ["todotreeview:filter-notes"] = function() local todo_view_focus = core.active_view:is(TodoTreeView) local previous_filter = view.filter local submit = function(text) view.filter = text if todo_view_focus then view.focus_index = 0 view.hovered_item = view:get_item_by_index(view.focus_index) view:update_scroll_position() end end local suggest = function(text) view.filter = text end local cancel = function(explicit) view.filter = previous_filter end core.command_view:enter("Filter Notes", { text = view.filter, submit = submit, suggest = suggest, cancel = cancel }) end, }) command.add( function() return core.active_view:is(TodoTreeView) end, { ["todotreeview:previous"] = function() if view.focus_index > 0 then view.focus_index = view.focus_index - 1 view.hovered_item = view:get_item_by_index(view.focus_index) view:update_scroll_position() end end, ["todotreeview:next"] = function() local next_index = view.focus_index + 1 local next_item = view:get_item_by_index(next_index) if next_item then view.focus_index = next_index view.hovered_item = next_item view:update_scroll_position() end end, ["todotreeview:collapse"] = function() if not view.hovered_item then return end if view.hovered_item.type == "file" then view.hovered_item.expanded = false else if view.hovered_item.type == "group" and view.hovered_item.expanded then view.hovered_item.expanded = false else if config.plugins.todotreeview.todo_mode == "file_tag" then view.hovered_item, view.focus_index = view:get_hovered_parent_file_tag() else view.hovered_item, view.focus_index = view:get_hovered_parent() end view:update_scroll_position() end end end, ["todotreeview:expand"] = function() if not view.hovered_item then return end if view.hovered_item.type == "file" or view.hovered_item.type == "group" then if view.hovered_item.expanded then command.perform("todotreeview:next") else view.hovered_item.expanded = true end end end, ["todotreeview:open"] = function() if not view.hovered_item then return end view:goto_hovered_item() view.hovered_item = nil end, ["todotreeview:release-focus"] = function() core.set_active_view( previous_view or core.root_view:get_primary_node().active_view ) view.hovered_item = nil end, }) keymap.add { ["ctrl+shift+t"] = "todotreeview:toggle" } keymap.add { ["ctrl+shift+e"] = "todotreeview:expand-items" } keymap.add { ["ctrl+shift+h"] = "todotreeview:hide-items" } keymap.add { ["ctrl+shift+b"] = "todotreeview:filter-notes" } keymap.add { ["up"] = "todotreeview:previous" } keymap.add { ["down"] = "todotreeview:next" } keymap.add { ["left"] = "todotreeview:collapse" } keymap.add { ["right"] = "todotreeview:expand" } keymap.add { ["return"] = "todotreeview:open" } keymap.add { ["escape"] = "todotreeview:release-focus" }