├── lite-todo-view.png ├── manifest.json ├── LICENSE ├── README.md ├── todotreeview.lua └── todotreeview-xl.lua /lite-todo-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drmargarido/TodoTreeView/HEAD/lite-todo-view.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "addons": [ 3 | { 4 | "id": "todotreeview", 5 | "version": "0.1.11", 6 | "mod_version": "3", 7 | "description": "Todo tree viewer for annotations in code like `TODO`, `BUG`, `FIX`, `IMPROVEMENT`", 8 | "path": "todotreeview-xl.lua" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Daniel Margarido 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TodoTreeView 2 | 3 | TODO View for https://github.com/rxi/lite 4 | 5 | 6 | ## Base Functionality 7 | 8 | This plugin will display notes that are in the code which start with: 9 | * TODO 10 | * FIX 11 | * FIXME 12 | * BUG 13 | * IMPROVEMENT 14 | Extra tags can be added if you add them to the `todo_tags` config. You can also 15 | configure their colors using the `tag_colors` field if wanted. 16 | 17 | You can select between two modes: 18 | * tag - To display the notes organized by the note type 19 | * file - To display the notes organized by file 20 | * file_tag - To display the notes organized by file and note type 21 | 22 | You can also define between two scopes: 23 | * all - Will display all the notes in the project and opened files all the time 24 | * focused - Will display the notes only for the currently opened file or all files 25 | if no file is open or focused. 26 | 27 | The plugin registers the following key bindings: 28 | * ctrl+shift+t - Toggles the visibility of the view 29 | * ctrl+shift+e - Expands all the groups 30 | * ctrl+shift+h - Hides all the groups 31 | * ctrl+shift+b - Filters the notes 32 | 33 | You can ignore specific directories or files by using the `ignore_paths` config. 34 | 35 | ## Demo 36 | 37 | Example of the `tag` view 38 | ![Todo plugin demo](/lite-todo-view.png) 39 | 40 | 41 | ## Instructions 42 | 43 | 1. To install the plugin just copy the `todotreeview.lua` file (or the 44 | `todotreeview-xl.lua` if you are using lite-xl) to the folder `data/plugins/` 45 | of the lite editor. 46 | 2. If you want to register extra tags or change the display mode or update any 47 | other of the settings you can edit your `data/user/init.lua` file. 48 | 3. If you are using lite-xl run the `core:open-user-module` command to open the 49 | user file. Also use `config.plugins.todotreeview` instead of just `config` to 50 | modify the multiple settings. 51 | ```lua 52 | local config = require "core.config" 53 | local common = require "core.common" 54 | 55 | -- Add extra tags 56 | table.insert(config.todo_tags, "CLEANUP") 57 | 58 | -- Set colors for the new tag 59 | config.tag_colors = { 60 | CLEANUP = { 61 | tag={common.color("#008000")}, 62 | tag_hover={common.color("#00d000")}, 63 | text={common.color("#004000")}, 64 | text_hover={common.color("#008000")}, 65 | } 66 | } 67 | 68 | -- Set the file color when in file mode 69 | config.todo_file_color = { 70 | name={common.color("#A267d9")}, 71 | hover={common.color("#6DaE46")}, 72 | } 73 | 74 | -- Change display mode 75 | config.todo_mode = "file" -- Or "file_tag" 76 | 77 | -- Change scope 78 | config.todo_scope = "focused" 79 | 80 | -- Change the separator between the tag and the text in file mode 81 | config.todo_separator = " -> " 82 | 83 | -- Change the default text if the note is empty 84 | config.todo_default_text = "---" 85 | 86 | -- Ignore directory and ignore specific file 87 | table.insert(config.ignore_paths, "winlib/") 88 | table.insert(config.ignore_paths, "README.md") 89 | ``` 90 | 91 | -------------------------------------------------------------------------------- /todotreeview.lua: -------------------------------------------------------------------------------- 1 | local core = require "core" 2 | local common = require "core.common" 3 | local command = require "core.command" 4 | local config = require "core.config" 5 | local keymap = require "core.keymap" 6 | local style = require "core.style" 7 | local View = require "core.view" 8 | local StatusView = require "core.statusview" 9 | local CommandView = require "core.commandview" 10 | local DocView = require "core.docview" 11 | 12 | local TodoTreeView = View:extend() 13 | 14 | config.todo_tags = {"TODO", "BUG", "FIX", "FIXME", "IMPROVEMENT"} 15 | config.tag_colors = { 16 | TODO = {tag=style.text, tag_hover=style.accent, text=style.text, text_hover=style.accent}, 17 | BUG = {tag=style.text, tag_hover=style.accent, text=style.text, text_hover=style.accent}, 18 | FIX = {tag=style.text, tag_hover=style.accent, text=style.text, text_hover=style.accent}, 19 | FIXME = {tag=style.text, tag_hover=style.accent, text=style.text, text_hover=style.accent}, 20 | IMPROVEMENT = {tag=style.text, tag_hover=style.accent, text=style.text, text_hover=style.accent}, 21 | } 22 | config.todo_file_color = { 23 | name = style.text, 24 | hover = style.accent 25 | } 26 | 27 | -- Paths or files to be ignored 28 | config.ignore_paths = {} 29 | 30 | -- Tells if the plugin should start with the nodes expanded 31 | config.todo_expanded = true 32 | 33 | -- 'tag' mode can be used to group the todos by tags 34 | -- 'file' mode can be used to group the todos by files 35 | -- 'file_tag' mode can be used to group the todos by files and then by tags inside the files 36 | config.todo_mode = "tag" 37 | 38 | -- Only used in file mode when the tag and the text are on the same line 39 | config.todo_separator = " - " 40 | 41 | -- Text displayed when the note is empty 42 | config.todo_default_text = "blank" 43 | 44 | local SCOPES = { 45 | ALL = "all", 46 | FOCUSED = "focused", 47 | } 48 | 49 | -- Scope of the displayed tags 50 | -- 'all' scope to show all tags from the project all the time 51 | -- 'focused' scope to show the tags from the currently focused file 52 | config.todo_scope = SCOPES.FOCUSED 53 | 54 | function TodoTreeView:new() 55 | TodoTreeView.super.new(self) 56 | self.scrollable = true 57 | self.focusable = false 58 | self.visible = true 59 | self.times_cache = {} 60 | self.cache = {} 61 | self.cache_updated = false 62 | self.init_size = true 63 | self.focus_index = 0 64 | self.filter = "" 65 | self.previous_focused_file = nil 66 | 67 | -- Items are generated from cache according to the mode 68 | self.items = {} 69 | end 70 | 71 | local function is_file_ignored(filename) 72 | for _, path in ipairs(config.ignore_paths) do 73 | local s, _ = filename:find(path) 74 | if s then 75 | return true 76 | end 77 | end 78 | 79 | return false 80 | end 81 | 82 | function TodoTreeView:is_file_in_scope(filename) 83 | if config.todo_scope == SCOPES.ALL then 84 | return true 85 | elseif config.todo_scope == SCOPES.FOCUSED then 86 | if core.active_view:is(DocView) then 87 | if core.active_view:is(CommandView) then 88 | if self.previous_focused_file == nil then 89 | return true 90 | else 91 | return self.previous_focused_file == filename 92 | end 93 | else 94 | return core.active_view.doc.filename == filename 95 | end 96 | elseif core.active_view:is(TodoTreeView) then 97 | if self.previous_focused_file == nil then 98 | return true 99 | else 100 | return self.previous_focused_file == filename 101 | end 102 | else 103 | return true 104 | end 105 | else 106 | assert(false, "Unknown scope defined ("..config.todo_scope..")") 107 | end 108 | end 109 | 110 | function TodoTreeView:get_all_files() 111 | local all_files = {} 112 | for _, file in ipairs(core.project_files) do 113 | if file.filename then 114 | all_files[file.filename] = file 115 | end 116 | end 117 | for _, file in ipairs(core.docs) do 118 | if file.filename and not all_files[file.filename] then 119 | all_files[file.filename] = { 120 | filename = file.filename, 121 | type = "file" 122 | } 123 | end 124 | end 125 | return all_files 126 | end 127 | 128 | function TodoTreeView:refresh_cache() 129 | local items = {} 130 | if not next(self.items) then 131 | items = self.items 132 | end 133 | self.updating_cache = true 134 | 135 | core.add_thread(function() 136 | for _, item in pairs(self:get_all_files()) do 137 | local ignored = is_file_ignored(item.filename) 138 | if not ignored and item.type == "file" then 139 | local cached = self:get_cached(item) 140 | 141 | if config.todo_mode == "file" then 142 | items[cached.filename] = cached 143 | elseif config.todo_mode == "file_tag" then 144 | local file_t = {} 145 | file_t.expanded = config.todo_expanded 146 | file_t.type = "file" 147 | file_t.tags = {} 148 | file_t.todos = {} 149 | file_t.filename = cached.filename 150 | file_t.abs_filename = cached.abs_filename 151 | items[cached.filename] = file_t 152 | for _, todo in ipairs(cached.todos) do 153 | local tag = todo.tag 154 | if not file_t.tags[tag] then 155 | local tag_t = {} 156 | tag_t.expanded = config.todo_expanded 157 | tag_t.type = "group" 158 | tag_t.todos = {} 159 | tag_t.tag = tag 160 | file_t.tags[tag] = tag_t 161 | end 162 | 163 | table.insert(file_t.tags[tag].todos, todo) 164 | end 165 | else 166 | for _, todo in ipairs(cached.todos) do 167 | local tag = todo.tag 168 | if not items[tag] then 169 | local t = {} 170 | t.expanded = config.todo_expanded 171 | t.type = "group" 172 | t.todos = {} 173 | t.tag = tag 174 | items[tag] = t 175 | end 176 | 177 | table.insert(items[tag].todos, todo) 178 | end 179 | end 180 | end 181 | end 182 | 183 | -- Copy expanded from old items 184 | if config.todo_mode == "tag" and next(self.items) then 185 | for tag, data in pairs(self.items) do 186 | if items[tag] then 187 | items[tag].expanded = data.expanded 188 | end 189 | end 190 | end 191 | 192 | self.items = items 193 | core.redraw = true 194 | self.cache_updated = true 195 | self.updating_cache = false 196 | end, self) 197 | end 198 | 199 | 200 | local function find_file_todos(t, filename) 201 | local fp = io.open(filename) 202 | if not fp then return t end 203 | local n = 1 204 | for line in fp:lines() do 205 | for _, todo_tag in ipairs(config.todo_tags) do 206 | -- Add spaces at the start and end of line so the pattern will pick 207 | -- tags at the start and at the end of lines 208 | local extended_line = " "..line.." " 209 | local match_str = "[^a-zA-Z_\"'`]"..todo_tag.."[^\"'a-zA-Z_`]+" 210 | local s, e = extended_line:find(match_str) 211 | if s then 212 | local d = {} 213 | d.type = "todo" 214 | d.tag = todo_tag 215 | d.filename = filename 216 | d.text = extended_line:sub(e+1) 217 | if d.text == "" then 218 | d.text = config.todo_default_text 219 | end 220 | d.line = n 221 | d.col = s 222 | table.insert(t, d) 223 | end 224 | core.redraw = true 225 | end 226 | if n % 100 == 0 then coroutine.yield() end 227 | n = n + 1 228 | core.redraw = true 229 | end 230 | fp:close() 231 | end 232 | 233 | 234 | function TodoTreeView:get_cached(item) 235 | local t = self.cache[item.filename] 236 | if not t then 237 | t = {} 238 | t.expanded = config.todo_expanded 239 | t.filename = item.filename 240 | t.abs_filename = system.absolute_path(item.filename) 241 | t.type = item.type 242 | t.todos = {} 243 | t.tags = {} 244 | find_file_todos(t.todos, t.filename) 245 | self.cache[t.filename] = t 246 | end 247 | return t 248 | end 249 | 250 | function TodoTreeView:get_name() 251 | return "Todo Tree" 252 | end 253 | 254 | 255 | function TodoTreeView:get_item_height() 256 | return style.font:get_height() + style.padding.y 257 | end 258 | 259 | 260 | function TodoTreeView:get_cached_time(doc) 261 | local t = self.times_cache[doc] 262 | if not t then 263 | local info = system.get_file_info(doc.filename) 264 | if not info then return nil end 265 | self.times_cache[doc] = info.modified 266 | end 267 | return t 268 | end 269 | 270 | function TodoTreeView:check_cache() 271 | local existing_docs = {} 272 | for _, doc in ipairs(core.docs) do 273 | if doc.filename then 274 | existing_docs[doc.filename] = true 275 | local info = system.get_file_info(doc.filename) 276 | local cached = self:get_cached_time(doc) 277 | if not info and cached then 278 | -- document deleted 279 | self.times_cache[doc] = nil 280 | self.cache[doc.filename] = nil 281 | self.cache_updated = false 282 | elseif cached and cached ~= info.modified then 283 | -- document modified 284 | self.times_cache[doc] = info.modified 285 | self.cache[doc.filename] = nil 286 | self.cache_updated = false 287 | elseif not cached then 288 | self.cache_updated = false 289 | end 290 | end 291 | end 292 | 293 | for _, file in ipairs(core.project_files) do 294 | existing_docs[file.filename] = true 295 | end 296 | 297 | -- Check for docs in cache that may not exist anymore 298 | -- for example: (Openend from outside of project and closed) 299 | for filename, doc in pairs(self.cache) do 300 | local exists = existing_docs[filename] 301 | if not exists then 302 | self.times_cache[doc] = nil 303 | self.cache[filename] = nil 304 | self.cache_updated = false 305 | end 306 | end 307 | 308 | if core.project_files ~= self.last_project_files then 309 | self.last_project_files = core.project_files 310 | self.cache_updated = false 311 | end 312 | end 313 | 314 | function TodoTreeView:each_item() 315 | self:check_cache() 316 | if not self.updating_cache and not self.cache_updated then 317 | self:refresh_cache() 318 | end 319 | 320 | return coroutine.wrap(function() 321 | local ox, oy = self:get_content_offset() 322 | local y = oy + style.padding.y 323 | local w = self.size.x 324 | local h = self:get_item_height() 325 | 326 | for _, item in pairs(self.items) do 327 | local in_scope = item.type == "group" or self:is_file_in_scope(item.filename) 328 | if in_scope and #item.todos > 0 then 329 | coroutine.yield(item, ox, y, w, h) 330 | y = y + h 331 | 332 | for _, todo in ipairs(item.todos) do 333 | if item.expanded then 334 | local in_todo = string.find(todo.text:lower(), self.filter:lower()) 335 | local todo_in_scope = self:is_file_in_scope(todo.filename) 336 | if todo_in_scope and (#self.filter == 0 or in_todo) then 337 | coroutine.yield(todo, ox, y, w, h) 338 | y = y + h 339 | end 340 | end 341 | end 342 | end 343 | 344 | if in_scope and item.tags then 345 | local first_tag = true 346 | for _, tag in pairs(item.tags) do 347 | if first_tag then 348 | coroutine.yield(item, ox, y, w, h) 349 | y = y + h 350 | first_tag = false 351 | end 352 | if item.expanded then 353 | coroutine.yield(tag, ox, y, w, h) 354 | y = y + h 355 | 356 | for _, todo in ipairs(tag.todos) do 357 | if item.expanded and tag.expanded then 358 | local in_todo = string.find(todo.text:lower(), self.filter:lower()) 359 | local todo_in_scope = self:is_file_in_scope(todo.filename) 360 | if todo_in_scope and (#self.filter == 0 or in_todo) then 361 | coroutine.yield(todo, ox, y, w, h) 362 | y = y + h 363 | end 364 | end 365 | end 366 | end 367 | end 368 | end 369 | 370 | end 371 | end) 372 | end 373 | 374 | 375 | function TodoTreeView:on_mouse_moved(px, py) 376 | self.hovered_item = nil 377 | for item, x,y,w,h in self:each_item() do 378 | if px > x and py > y and px <= x + w and py <= y + h then 379 | self.hovered_item = item 380 | break 381 | end 382 | end 383 | end 384 | 385 | function TodoTreeView:goto_hovered_item() 386 | if not self.hovered_item then 387 | return 388 | end 389 | 390 | if self.hovered_item.type == "group" or self.hovered_item.type == "file" then 391 | return 392 | end 393 | 394 | core.try(function() 395 | local i = self.hovered_item 396 | local dv = core.root_view:open_doc(core.open_doc(i.filename)) 397 | core.root_view.root_node:update_layout() 398 | dv.doc:set_selection(i.line, i.col) 399 | dv:scroll_to_line(i.line, false, true) 400 | end) 401 | end 402 | 403 | function TodoTreeView:on_mouse_pressed(button, x, y) 404 | if not self.hovered_item then 405 | return 406 | elseif self.hovered_item.type == "file" 407 | or self.hovered_item.type == "group" then 408 | self.hovered_item.expanded = not self.hovered_item.expanded 409 | else 410 | self:goto_hovered_item() 411 | end 412 | end 413 | 414 | 415 | function TodoTreeView:update() 416 | -- Update focus 417 | if core.active_view:is(DocView) then 418 | if not core.active_view:is(CommandView) then 419 | self.previous_focused_file = core.active_view.doc.filename 420 | end 421 | elseif core.active_view:is(TodoTreeView) then 422 | 423 | else 424 | self.previous_focused_file = nil 425 | end 426 | 427 | 428 | self.scroll.to.y = math.max(0, self.scroll.to.y) 429 | 430 | -- update width 431 | local dest = self.visible and config.treeview_size or 0 432 | if self.init_size then 433 | self.size.x = dest 434 | self.init_size = false 435 | else 436 | self:move_towards(self.size, "x", dest) 437 | end 438 | 439 | TodoTreeView.super.update(self) 440 | end 441 | 442 | 443 | function TodoTreeView:draw() 444 | self:draw_background(style.background2) 445 | 446 | --local h = self:get_item_height() 447 | local icon_width = style.icon_font:get_width("D") 448 | local spacing = style.font:get_width(" ") * 2 449 | local root_depth = 0 450 | 451 | for item, x,y,w,h in self:each_item() do 452 | local text_color = style.text 453 | local tag_color = style.text 454 | local file_color = config.todo_file_color.name or style.text 455 | if config.tag_colors[item.tag] then 456 | text_color = config.tag_colors[item.tag].text or style.text 457 | tag_color = config.tag_colors[item.tag].tag or style.text 458 | end 459 | 460 | -- hovered item background 461 | if item == self.hovered_item then 462 | renderer.draw_rect(x, y, w, h, style.line_highlight) 463 | text_color = style.accent 464 | tag_color = style.accent 465 | file_color = config.todo_file_color.hover or style.accent 466 | if config.tag_colors[item.tag] then 467 | text_color = config.tag_colors[item.tag].text_hover or style.accent 468 | tag_color = config.tag_colors[item.tag].tag_hover or style.accent 469 | end 470 | end 471 | 472 | -- icons 473 | local item_depth = 0 474 | x = x + (item_depth - root_depth) * style.padding.x + style.padding.x 475 | if item.type == "file" then 476 | local icon1 = item.expanded and "-" or "+" 477 | common.draw_text(style.icon_font, file_color, icon1, nil, x, y, 0, h) 478 | x = x + style.padding.x 479 | common.draw_text(style.icon_font, file_color, "f", nil, x, y, 0, h) 480 | x = x + icon_width 481 | elseif item.type == "group" then 482 | if config.todo_mode == "file_tag" then 483 | x = x + style.padding.x * 0.75 484 | end 485 | 486 | local icon1 = item.expanded and "-" or ">" 487 | common.draw_text(style.icon_font, tag_color, icon1, nil, x, y, 0, h) 488 | x = x + icon_width / 2 489 | else 490 | if config.todo_mode == "tag" then 491 | x = x + style.padding.x 492 | else 493 | x = x + style.padding.x * 1.5 494 | end 495 | common.draw_text(style.icon_font, text_color, "i", nil, x, y, 0, h) 496 | x = x + icon_width 497 | end 498 | 499 | -- text 500 | x = x + spacing 501 | if item.type == "file" then 502 | common.draw_text(style.font, file_color, item.filename, nil, x, y, 0, h) 503 | elseif item.type == "group" then 504 | common.draw_text(style.font, tag_color, item.tag, nil, x, y, 0, h) 505 | else 506 | if config.todo_mode == "file" then 507 | common.draw_text(style.font, tag_color, item.tag, nil, x, y, 0, h) 508 | x = x + style.font:get_width(item.tag) 509 | common.draw_text(style.font, text_color, config.todo_separator..item.text, nil, x, y, 0, h) 510 | else 511 | common.draw_text(style.font, text_color, item.text, nil, x, y, 0, h) 512 | end 513 | end 514 | end 515 | end 516 | 517 | function TodoTreeView:get_item_by_index(index) 518 | local i = 0 519 | for item in self:each_item() do 520 | if index == i then 521 | return item 522 | end 523 | i = i + 1 524 | end 525 | return nil 526 | end 527 | 528 | function TodoTreeView:get_hovered_parent_file_tag() 529 | local file_parent = nil 530 | local file_parent_index = 0 531 | local group_parent = nil 532 | local group_parent_index = 0 533 | local i = 0 534 | for item in self:each_item() do 535 | if item.type == "file" then 536 | file_parent = item 537 | file_parent_index = i 538 | end 539 | if item.type == "group" then 540 | group_parent = item 541 | group_parent_index = i 542 | end 543 | if i == self.focus_index then 544 | if item.type == "file" or item.type == "group" then 545 | return file_parent, file_parent_index 546 | else 547 | return group_parent, group_parent_index 548 | end 549 | end 550 | i = i + 1 551 | end 552 | return nil, 0 553 | end 554 | 555 | function TodoTreeView:get_hovered_parent() 556 | local parent = nil 557 | local parent_index = 0 558 | local i = 0 559 | for item in self:each_item() do 560 | if item.type == "group" or item.type == "file" then 561 | parent = item 562 | parent_index = i 563 | end 564 | if i == self.focus_index then 565 | return parent, parent_index 566 | end 567 | i = i + 1 568 | end 569 | return nil, 0 570 | end 571 | 572 | function TodoTreeView:update_scroll_position() 573 | local h = self:get_item_height() 574 | local _, min_y, _, max_y = self:get_content_bounds() 575 | local start_row = math.floor(min_y / h) 576 | local end_row = math.floor(max_y / h) 577 | if self.focus_index < start_row then 578 | self.scroll.to.y = self.focus_index * h 579 | end 580 | if self.focus_index + 1 > end_row then 581 | self.scroll.to.y = (self.focus_index * h) - self.size.y + h 582 | end 583 | end 584 | 585 | -- init 586 | local view = TodoTreeView() 587 | local node = core.root_view:get_active_node() 588 | view.size.x = config.treeview_size 589 | node:split("right", view, true) 590 | 591 | local get_items = StatusView.get_items 592 | function StatusView:get_items() 593 | local left, right = get_items(self) 594 | 595 | if not core.active_view:is(CommandView) and #view.filter > 0 then 596 | table.insert(right, 1, StatusView.separator) 597 | table.insert(right, 1, string.format("Filter: %s", view.filter)) 598 | end 599 | 600 | return left, right 601 | end 602 | 603 | -- register commands and keymap 604 | local previous_view = nil 605 | command.add(nil, { 606 | ["todotreeview:toggle"] = function() 607 | view.visible = not view.visible 608 | end, 609 | 610 | ["todotreeview:expand-items"] = function() 611 | for _, item in pairs(view.items) do 612 | item.expanded = true 613 | end 614 | end, 615 | 616 | ["todotreeview:hide-items"] = function() 617 | for _, item in pairs(view.items) do 618 | item.expanded = false 619 | end 620 | end, 621 | 622 | ["todotreeview:toggle-focus"] = function() 623 | if not core.active_view:is(TodoTreeView) then 624 | previous_view = core.active_view 625 | core.set_active_view(view) 626 | view.hovered_item = view:get_item_by_index(view.focus_index) 627 | else 628 | command.perform("todotreeview:release-focus") 629 | end 630 | end, 631 | 632 | ["todotreeview:filter-notes"] = function() 633 | local todo_view_focus = core.active_view:is(TodoTreeView) 634 | local previous_filter = view.filter 635 | core.command_view:set_text(view.filter, true) 636 | local submit = function(text) 637 | view.filter = text 638 | if todo_view_focus then 639 | view.focus_index = 0 640 | view.hovered_item = view:get_item_by_index(view.focus_index) 641 | view:update_scroll_position() 642 | end 643 | end 644 | local suggest = function(text) 645 | view.filter = text 646 | end 647 | local cancel = function(explicit) 648 | view.filter = previous_filter 649 | end 650 | core.command_view:enter("Filter Notes", submit, suggest, cancel) 651 | end, 652 | }) 653 | 654 | command.add( 655 | function() 656 | return core.active_view:is(TodoTreeView) 657 | end, { 658 | ["todotreeview:previous"] = function() 659 | if view.focus_index > 0 then 660 | view.focus_index = view.focus_index - 1 661 | view.hovered_item = view:get_item_by_index(view.focus_index) 662 | view:update_scroll_position() 663 | end 664 | end, 665 | 666 | ["todotreeview:next"] = function() 667 | local next_index = view.focus_index + 1 668 | local next_item = view:get_item_by_index(next_index) 669 | if next_item then 670 | view.focus_index = next_index 671 | view.hovered_item = next_item 672 | view:update_scroll_position() 673 | end 674 | end, 675 | 676 | ["todotreeview:collapse"] = function() 677 | if not view.hovered_item then 678 | return 679 | end 680 | 681 | if view.hovered_item.type == "file" then 682 | view.hovered_item.expanded = false 683 | else 684 | if view.hovered_item.type == "group" and view.hovered_item.expanded then 685 | view.hovered_item.expanded = false 686 | else 687 | if config.todo_mode == "file_tag" then 688 | view.hovered_item, view.focus_index = view:get_hovered_parent_file_tag() 689 | else 690 | view.hovered_item, view.focus_index = view:get_hovered_parent() 691 | end 692 | 693 | view:update_scroll_position() 694 | end 695 | end 696 | end, 697 | 698 | ["todotreeview:expand"] = function() 699 | if not view.hovered_item then 700 | return 701 | end 702 | 703 | if view.hovered_item.type == "file" or view.hovered_item.type == "group" then 704 | if view.hovered_item.expanded then 705 | command.perform("todotreeview:next") 706 | else 707 | view.hovered_item.expanded = true 708 | end 709 | end 710 | end, 711 | 712 | ["todotreeview:open"] = function() 713 | if not view.hovered_item then 714 | return 715 | end 716 | 717 | view:goto_hovered_item() 718 | view.hovered_item = nil 719 | end, 720 | 721 | ["todotreeview:release-focus"] = function() 722 | core.set_active_view( 723 | previous_view or core.root_view:get_primary_node().active_view 724 | ) 725 | view.hovered_item = nil 726 | end, 727 | }) 728 | 729 | keymap.add { ["ctrl+shift+t"] = "todotreeview:toggle" } 730 | keymap.add { ["ctrl+shift+e"] = "todotreeview:expand-items" } 731 | keymap.add { ["ctrl+shift+h"] = "todotreeview:hide-items" } 732 | keymap.add { ["ctrl+shift+b"] = "todotreeview:filter-notes" } 733 | keymap.add { ["up"] = "todotreeview:previous" } 734 | keymap.add { ["down"] = "todotreeview:next" } 735 | keymap.add { ["left"] = "todotreeview:collapse" } 736 | keymap.add { ["right"] = "todotreeview:expand" } 737 | keymap.add { ["return"] = "todotreeview:open" } 738 | keymap.add { ["escape"] = "todotreeview:release-focus" } 739 | 740 | 741 | -------------------------------------------------------------------------------- /todotreeview-xl.lua: -------------------------------------------------------------------------------- 1 | -- mod-version:3 2 | local core = require "core" 3 | local common = require "core.common" 4 | local command = require "core.command" 5 | local config = require "core.config" 6 | local keymap = require "core.keymap" 7 | local style = require "core.style" 8 | local View = require "core.view" 9 | local CommandView = require "core.commandview" 10 | local DocView = require "core.docview" 11 | 12 | local TodoTreeView = View:extend() 13 | 14 | local SCOPES = { 15 | ALL = "all", 16 | FOCUSED = "focused", 17 | } 18 | 19 | config.plugins.todotreeview = common.merge({ 20 | todo_tags = {"TODO", "BUG", "FIX", "FIXME", "IMPROVEMENT"}, 21 | tag_colors = { 22 | TODO = {tag=style.text, tag_hover=style.accent, text=style.text, text_hover=style.accent}, 23 | BUG = {tag=style.text, tag_hover=style.accent, text=style.text, text_hover=style.accent}, 24 | FIX = {tag=style.text, tag_hover=style.accent, text=style.text, text_hover=style.accent}, 25 | FIXME = {tag=style.text, tag_hover=style.accent, text=style.text, text_hover=style.accent}, 26 | IMPROVEMENT = {tag=style.text, tag_hover=style.accent, text=style.text, text_hover=style.accent}, 27 | }, 28 | todo_file_color = { 29 | name=style.text, 30 | hover=style.accent 31 | }, 32 | -- Paths or files to be ignored 33 | ignore_paths = {}, 34 | 35 | -- Tells if the plugin should start with the nodes expanded 36 | todo_expanded = true, 37 | 38 | -- 'tag' mode can be used to group the todos by tags 39 | -- 'file' mode can be used to group the todos by files 40 | -- 'file_tag' mode can be used to group the todos by files and then by tags inside the files 41 | todo_mode = "tag", 42 | 43 | treeview_size = 200 * SCALE, -- default size 44 | 45 | -- Only used in file mode when the tag and the text are on the same line 46 | todo_separator = " - ", 47 | 48 | -- Text displayed when the note is empty 49 | todo_default_text = "blank", 50 | 51 | -- Scope of the displayed tags 52 | -- 'all' scope to show all tags from the project all the time 53 | -- 'focused' scope to show the tags from the currently focused file 54 | todo_scope = SCOPES.ALL, 55 | 56 | -- The config specification used by the settings gui 57 | config_spec = { 58 | name = "TodoTreeView", 59 | { 60 | label = "Todo Tags", 61 | description = "List of tags to parse and show in the todo panel.", 62 | path = "todo_tags", 63 | type = "list_strings", 64 | default = {"TODO", "BUG", "FIX", "FIXME", "IMPROVEMENT"}, 65 | }, 66 | { 67 | label = "Paths to ignore", 68 | description = "Paths to be ignored when parsing the tags.", 69 | path = "ignore_paths", 70 | type = "list_strings", 71 | default = {} 72 | }, 73 | { 74 | label = "Groups Expanded", 75 | description = "Defines if the groups (Tags / Files) should start expanded.", 76 | path = "todo_expanded", 77 | type = "toggle", 78 | default = true, 79 | }, 80 | { 81 | label = "Mode for the todos", 82 | description = "Mode for the todos to be displayed.", 83 | path = "todo_mode", 84 | type = "selection", 85 | default = "tag", 86 | values = { 87 | {"Tag", "tag"}, 88 | {"File", "file"}, 89 | {"FileTag", "file_tag"} 90 | } 91 | }, 92 | { 93 | label = "Treeview Size", 94 | description = "Size of the todo tree view panel.", 95 | path = "treeview_size", 96 | type = "number", 97 | default = 200 * SCALE, 98 | }, 99 | { 100 | label = "Todo separator", 101 | description = "Separator used in file mode when the tag and the text are on the same line.", 102 | path = "todo_separator", 103 | type = "string", 104 | default = " - ", 105 | }, 106 | { 107 | label = "Empty Default Text", 108 | description = "Default text displayed for a note when it has no text.", 109 | path = "todo_default_text", 110 | type = "string", 111 | default = " - ", 112 | }, 113 | { 114 | label = "Todo Scope", 115 | description = "Scope for the notes to be picked, all notes or currently focused file.", 116 | path = "todo_scope", 117 | type = "selection", 118 | default = "all", 119 | values = { 120 | {"All", "all"}, 121 | {"Focused", "focused"}, 122 | } 123 | }, 124 | } 125 | }, config.plugins.todotreeview) 126 | 127 | local icon_small_font = style.icon_font:copy(10 * SCALE) 128 | 129 | function TodoTreeView:new() 130 | TodoTreeView.super.new(self) 131 | self.scrollable = true 132 | self.focusable = false 133 | self.visible = true 134 | self.times_cache = {} 135 | self.cache = {} 136 | self.cache_updated = false 137 | self.init_size = true 138 | self.focus_index = 0 139 | self.filter = "" 140 | self.previous_focused_file = nil 141 | 142 | -- Items are generated from cache according to the mode 143 | self.items = {} 144 | end 145 | 146 | local function is_file_ignored(filename) 147 | for _, path in ipairs(config.plugins.todotreeview.ignore_paths) do 148 | local s, _ = filename:find(path) 149 | if s then 150 | return true 151 | end 152 | end 153 | 154 | return false 155 | end 156 | 157 | function TodoTreeView:is_file_in_scope(filename) 158 | if config.plugins.todotreeview.todo_scope == SCOPES.ALL then 159 | return true 160 | elseif config.plugins.todotreeview.todo_scope == SCOPES.FOCUSED then 161 | if core.active_view:is(CommandView) or core.active_view:is(TodoTreeView) then 162 | if self.previous_focused_file then 163 | return self.previous_focused_file == filename 164 | end 165 | elseif core.active_view:is(DocView) then 166 | return core.active_view.doc.filename == filename 167 | end 168 | return true 169 | else 170 | assert(false, "Unknown scope defined ("..config.plugins.todotreeview.todo_scope..")") 171 | end 172 | end 173 | 174 | function TodoTreeView.get_all_files() 175 | local all_files = {} 176 | for _, file in ipairs(core.project_files) do 177 | if file.filename then 178 | all_files[file.filename] = file 179 | end 180 | end 181 | for _, file in ipairs(core.docs) do 182 | if file.filename and not all_files[file.filename] then 183 | all_files[file.filename] = { 184 | filename = file.filename, 185 | type = "file" 186 | } 187 | end 188 | end 189 | return all_files 190 | end 191 | 192 | function TodoTreeView:refresh_cache() 193 | local items = {} 194 | if not next(self.items) then 195 | items = self.items 196 | end 197 | self.updating_cache = true 198 | 199 | core.add_thread(function() 200 | for _, item in pairs(self.get_all_files()) do 201 | local ignored = is_file_ignored(item.filename) 202 | if not ignored and item.type == "file" then 203 | local cached = self:get_cached(item) 204 | 205 | if config.plugins.todotreeview.todo_mode == "file" then 206 | items[cached.filename] = cached 207 | elseif config.plugins.todotreeview.todo_mode == "file_tag" then 208 | local file_t = {} 209 | file_t.expanded = config.plugins.todotreeview.todo_expanded 210 | file_t.type = "file" 211 | file_t.tags = {} 212 | file_t.todos = {} 213 | file_t.filename = cached.filename 214 | file_t.abs_filename = cached.abs_filename 215 | items[cached.filename] = file_t 216 | for _, todo in ipairs(cached.todos) do 217 | local tag = todo.tag 218 | if not file_t.tags[tag] then 219 | local tag_t = {} 220 | tag_t.expanded = config.plugins.todotreeview.todo_expanded 221 | tag_t.type = "group" 222 | tag_t.todos = {} 223 | tag_t.tag = tag 224 | file_t.tags[tag] = tag_t 225 | end 226 | 227 | table.insert(file_t.tags[tag].todos, todo) 228 | end 229 | else 230 | for _, todo in ipairs(cached.todos) do 231 | local tag = todo.tag 232 | if not items[tag] then 233 | local t = {} 234 | t.expanded = config.plugins.todotreeview.todo_expanded 235 | t.type = "group" 236 | t.todos = {} 237 | t.tag = tag 238 | items[tag] = t 239 | end 240 | 241 | table.insert(items[tag].todos, todo) 242 | end 243 | end 244 | end 245 | end 246 | 247 | -- Copy expanded from old items 248 | if config.plugins.todotreeview.todo_mode == "tag" and next(self.items) then 249 | for tag, data in pairs(self.items) do 250 | if items[tag] then 251 | items[tag].expanded = data.expanded 252 | end 253 | end 254 | end 255 | 256 | self.items = items 257 | core.redraw = true 258 | self.cache_updated = true 259 | self.updating_cache = false 260 | end, self) 261 | end 262 | 263 | 264 | local function find_file_todos(t, filename) 265 | local fp = io.open(filename) 266 | if not fp then return t end 267 | local n = 1 268 | for line in fp:lines() do 269 | for _, todo_tag in ipairs(config.plugins.todotreeview.todo_tags) do 270 | -- Add spaces at the start and end of line so the pattern will pick 271 | -- tags at the start and at the end of lines 272 | local extended_line = " "..line.." " 273 | local match_str = "[^a-zA-Z_\"'`]"..todo_tag.."[^\"'a-zA-Z_`]+" 274 | local s, e = extended_line:find(match_str) 275 | if s then 276 | local d = {} 277 | d.type = "todo" 278 | d.tag = todo_tag 279 | d.filename = filename 280 | d.text = extended_line:sub(e+1) 281 | if d.text == "" then 282 | d.text = config.plugins.todotreeview.todo_default_text 283 | end 284 | d.line = n 285 | d.col = s 286 | table.insert(t, d) 287 | end 288 | core.redraw = true 289 | end 290 | if n % 100 == 0 then coroutine.yield() end 291 | n = n + 1 292 | core.redraw = true 293 | end 294 | fp:close() 295 | end 296 | 297 | 298 | function TodoTreeView:get_cached(item) 299 | local t = self.cache[item.filename] 300 | if not t then 301 | t = {} 302 | t.expanded = config.plugins.todotreeview.todo_expanded 303 | t.filename = item.filename 304 | t.abs_filename = system.absolute_path(item.filename) 305 | t.type = item.type 306 | t.todos = {} 307 | t.tags = {} 308 | find_file_todos(t.todos, t.filename) 309 | self.cache[t.filename] = t 310 | end 311 | return t 312 | end 313 | 314 | 315 | function TodoTreeView:get_name() 316 | return "Todo Tree" 317 | end 318 | 319 | function TodoTreeView:set_target_size(axis, value) 320 | if axis == "x" then 321 | config.plugins.todotreeview.treeview_size = value 322 | return true 323 | end 324 | end 325 | 326 | function TodoTreeView:get_item_height() 327 | return style.font:get_height() + style.padding.y 328 | end 329 | 330 | 331 | function TodoTreeView:get_cached_time(doc) 332 | local t = self.times_cache[doc] 333 | if not t then 334 | local info = system.get_file_info(doc.filename) 335 | if not info then return nil end 336 | self.times_cache[doc] = info.modified 337 | end 338 | return t 339 | end 340 | 341 | 342 | function TodoTreeView:check_cache() 343 | local existing_docs = {} 344 | for _, doc in ipairs(core.docs) do 345 | if doc.filename then 346 | existing_docs[doc.filename] = true 347 | local info = system.get_file_info(doc.filename) 348 | local cached = self:get_cached_time(doc) 349 | if not info and cached then 350 | -- document deleted 351 | self.times_cache[doc] = nil 352 | self.cache[doc.filename] = nil 353 | self.cache_updated = false 354 | elseif cached and cached ~= info.modified then 355 | -- document modified 356 | self.times_cache[doc] = info.modified 357 | self.cache[doc.filename] = nil 358 | self.cache_updated = false 359 | elseif not cached then 360 | self.cache_updated = false 361 | end 362 | end 363 | end 364 | 365 | for _, file in ipairs(core.project_files) do 366 | existing_docs[file.filename] = true 367 | end 368 | 369 | -- Check for docs in cache that may not exist anymore 370 | -- for example: (Openend from outside of project and closed) 371 | for filename, doc in pairs(self.cache) do 372 | local exists = existing_docs[filename] 373 | if not exists then 374 | self.times_cache[doc] = nil 375 | self.cache[filename] = nil 376 | self.cache_updated = false 377 | end 378 | end 379 | 380 | if core.project_files ~= self.last_project_files then 381 | self.last_project_files = core.project_files 382 | self.cache_updated = false 383 | end 384 | end 385 | 386 | function TodoTreeView:each_item() 387 | self:check_cache() 388 | if not self.updating_cache and not self.cache_updated then 389 | self:refresh_cache() 390 | end 391 | 392 | return coroutine.wrap(function() 393 | local ox, oy = self:get_content_offset() 394 | local y = oy + style.padding.y 395 | local w = self.size.x 396 | local h = self:get_item_height() 397 | 398 | for filename, item in pairs(self.items) do 399 | local in_scope = item.type == "group" or self:is_file_in_scope(item.filename) 400 | if in_scope and #item.todos > 0 then 401 | coroutine.yield(item, ox, y, w, h) 402 | y = y + h 403 | 404 | for _, todo in ipairs(item.todos) do 405 | if item.expanded then 406 | local in_todo = string.find(todo.text:lower(), self.filter:lower()) 407 | local todo_in_scope = self:is_file_in_scope(todo.filename) 408 | if todo_in_scope and (#self.filter == 0 or in_todo) then 409 | coroutine.yield(todo, ox, y, w, h) 410 | y = y + h 411 | end 412 | end 413 | end 414 | 415 | end 416 | if in_scope and item.tags then 417 | local first_tag = true 418 | for _, tag in pairs(item.tags) do 419 | if first_tag then 420 | coroutine.yield(item, ox, y, w, h) 421 | y = y + h 422 | first_tag = false 423 | end 424 | if item.expanded then 425 | coroutine.yield(tag, ox, y, w, h) 426 | y = y + h 427 | 428 | for _, todo in ipairs(tag.todos) do 429 | if item.expanded and tag.expanded then 430 | local in_todo = string.find(todo.text:lower(), self.filter:lower()) 431 | local todo_in_scope = self:is_file_in_scope(todo.filename) 432 | if todo_in_scope and (#self.filter == 0 or in_todo) then 433 | coroutine.yield(todo, ox, y, w, h) 434 | y = y + h 435 | end 436 | end 437 | end 438 | end 439 | end 440 | end 441 | 442 | end 443 | end) 444 | end 445 | 446 | 447 | function TodoTreeView:on_mouse_moved(px, py) 448 | self.hovered_item = nil 449 | for item, x,y,w,h in self:each_item() do 450 | if px > x and py > y and px <= x + w and py <= y + h then 451 | self.hovered_item = item 452 | break 453 | end 454 | end 455 | end 456 | 457 | function TodoTreeView:goto_hovered_item() 458 | if not self.hovered_item then 459 | return 460 | end 461 | 462 | if self.hovered_item.type == "group" or self.hovered_item.type == "file" then 463 | return 464 | end 465 | 466 | core.try(function() 467 | local i = self.hovered_item 468 | local dv = core.root_view:open_doc(core.open_doc(i.filename)) 469 | core.root_view.root_node:update_layout() 470 | dv.doc:set_selection(i.line, i.col) 471 | dv:scroll_to_line(i.line, false, true) 472 | end) 473 | end 474 | 475 | function TodoTreeView:on_mouse_pressed(button, x, y) 476 | if not self.hovered_item then 477 | return 478 | elseif self.hovered_item.type == "file" 479 | or self.hovered_item.type == "group" then 480 | self.hovered_item.expanded = not self.hovered_item.expanded 481 | else 482 | self:goto_hovered_item() 483 | end 484 | end 485 | 486 | 487 | function TodoTreeView:update() 488 | -- Update focus 489 | if core.active_view:is(DocView) then 490 | self.previous_focused_file = core.active_view.doc.filename 491 | elseif core.active_view:is(CommandView) or core.active_view:is(TodoTreeView) then 492 | -- Do nothing 493 | else 494 | self.previous_focused_file = nil 495 | end 496 | 497 | self.scroll.to.y = math.max(0, self.scroll.to.y) 498 | 499 | -- update width 500 | local dest = self.visible and config.plugins.todotreeview.treeview_size or 0 501 | if self.init_size then 502 | self.size.x = dest 503 | self.init_size = false 504 | else 505 | self:move_towards(self.size, "x", dest) 506 | end 507 | 508 | TodoTreeView.super.update(self) 509 | end 510 | 511 | 512 | function TodoTreeView:draw() 513 | self:draw_background(style.background2) 514 | 515 | --local h = self:get_item_height() 516 | local icon_width = style.icon_font:get_width("D") 517 | local spacing = style.font:get_width(" ") * 2 518 | local root_depth = 0 519 | 520 | for item, x,y,w,h in self:each_item() do 521 | local text_color = style.text 522 | local tag_color = style.text 523 | local file_color = config.plugins.todotreeview.todo_file_color.name or style.text 524 | if config.plugins.todotreeview.tag_colors[item.tag] then 525 | text_color = config.plugins.todotreeview.tag_colors[item.tag].text or style.text 526 | tag_color = config.plugins.todotreeview.tag_colors[item.tag].tag or style.text 527 | end 528 | 529 | -- hovered item background 530 | if item == self.hovered_item then 531 | renderer.draw_rect(x, y, w, h, style.line_highlight) 532 | text_color = style.accent 533 | tag_color = style.accent 534 | file_color = config.plugins.todotreeview.todo_file_color.hover or style.accent 535 | if config.plugins.todotreeview.tag_colors[item.tag] then 536 | text_color = config.plugins.todotreeview.tag_colors[item.tag].text_hover or style.accent 537 | tag_color = config.plugins.todotreeview.tag_colors[item.tag].tag_hover or style.accent 538 | end 539 | end 540 | 541 | -- icons 542 | local item_depth = 0 543 | x = x + (item_depth - root_depth) * style.padding.x + style.padding.x 544 | if item.type == "file" then 545 | local icon1 = item.expanded and "-" or "+" 546 | common.draw_text(style.icon_font, file_color, icon1, nil, x, y, 0, h) 547 | x = x + style.padding.x 548 | common.draw_text(style.icon_font, file_color, "f", nil, x, y, 0, h) 549 | x = x + icon_width 550 | elseif item.type == "group" then 551 | if config.plugins.todotreeview.todo_mode == "file_tag" then 552 | x = x + style.padding.x * 0.75 553 | end 554 | 555 | if item.expanded then 556 | common.draw_text(style.icon_font, tag_color, "-", nil, x, y, 0, h) 557 | else 558 | common.draw_text(icon_small_font, tag_color, ">", nil, x, y, 0, h) 559 | end 560 | x = x + icon_width / 2 561 | else 562 | if config.plugins.todotreeview.todo_mode == "tag" then 563 | x = x + style.padding.x 564 | else 565 | x = x + style.padding.x * 1.5 566 | end 567 | common.draw_text(style.icon_font, text_color, "i", nil, x, y, 0, h) 568 | x = x + icon_width 569 | end 570 | 571 | -- text 572 | x = x + spacing 573 | if item.type == "file" then 574 | common.draw_text(style.font, file_color, item.filename, nil, x, y, 0, h) 575 | elseif item.type == "group" then 576 | common.draw_text(style.font, tag_color, item.tag, nil, x, y, 0, h) 577 | else 578 | if config.plugins.todotreeview.todo_mode == "file" then 579 | common.draw_text(style.font, tag_color, item.tag, nil, x, y, 0, h) 580 | x = x + style.font:get_width(item.tag) 581 | common.draw_text(style.font, text_color, config.plugins.todotreeview.todo_separator..item.text, nil, x, y, 0, h) 582 | else 583 | common.draw_text(style.font, text_color, item.text, nil, x, y, 0, h) 584 | end 585 | end 586 | end 587 | end 588 | 589 | function TodoTreeView:get_item_by_index(index) 590 | local i = 0 591 | for item in self:each_item() do 592 | if index == i then 593 | return item 594 | end 595 | i = i + 1 596 | end 597 | return nil 598 | end 599 | 600 | function TodoTreeView:get_hovered_parent_file_tag() 601 | local file_parent = nil 602 | local file_parent_index = 0 603 | local group_parent = nil 604 | local group_parent_index = 0 605 | local i = 0 606 | for item in self:each_item() do 607 | if item.type == "file" then 608 | file_parent = item 609 | file_parent_index = i 610 | end 611 | if item.type == "group" then 612 | group_parent = item 613 | group_parent_index = i 614 | end 615 | if i == self.focus_index then 616 | if item.type == "file" or item.type == "group" then 617 | return file_parent, file_parent_index 618 | else 619 | return group_parent, group_parent_index 620 | end 621 | end 622 | i = i + 1 623 | end 624 | return nil, 0 625 | end 626 | 627 | function TodoTreeView:get_hovered_parent() 628 | local parent = nil 629 | local parent_index = 0 630 | local i = 0 631 | for item in self:each_item() do 632 | if item.type == "group" or item.type == "file" then 633 | parent = item 634 | parent_index = i 635 | end 636 | if i == self.focus_index then 637 | return parent, parent_index 638 | end 639 | i = i + 1 640 | end 641 | return nil, 0 642 | end 643 | 644 | function TodoTreeView:update_scroll_position() 645 | local h = self:get_item_height() 646 | local _, min_y, _, max_y = self:get_content_bounds() 647 | local start_row = math.floor(min_y / h) 648 | local end_row = math.floor(max_y / h) 649 | if self.focus_index < start_row then 650 | self.scroll.to.y = self.focus_index * h 651 | end 652 | if self.focus_index + 1 > end_row then 653 | self.scroll.to.y = (self.focus_index * h) - self.size.y + h 654 | end 655 | end 656 | 657 | -- init 658 | local view = TodoTreeView() 659 | local node = core.root_view:get_active_node() 660 | view.size.x = config.plugins.todotreeview.treeview_size 661 | node:split("right", view, {x=true}, true) 662 | 663 | core.status_view:add_item({ 664 | predicate = function() 665 | return #view.filter > 0 and core.active_view and not core.active_view:is(CommandView) 666 | end, 667 | name = "todotreeview:filter", 668 | alignment = core.status_view.Item.RIGHT, 669 | get_item = function() 670 | return { 671 | style.text, 672 | string.format("Filter: %s", view.filter) 673 | } 674 | end, 675 | position = 1, 676 | tooltip = "Todos filtered by", 677 | separator = core.status_view.separator2 678 | }) 679 | 680 | -- register commands and keymap 681 | local previous_view = nil 682 | command.add(nil, { 683 | ["todotreeview:toggle"] = function() 684 | view.visible = not view.visible 685 | end, 686 | 687 | ["todotreeview:expand-items"] = function() 688 | for _, item in pairs(view.items) do 689 | item.expanded = true 690 | end 691 | end, 692 | 693 | ["todotreeview:hide-items"] = function() 694 | for _, item in pairs(view.items) do 695 | item.expanded = false 696 | end 697 | end, 698 | 699 | ["todotreeview:toggle-focus"] = function() 700 | if not core.active_view:is(TodoTreeView) then 701 | previous_view = core.active_view 702 | core.set_active_view(view) 703 | view.hovered_item = view:get_item_by_index(view.focus_index) 704 | else 705 | command.perform("todotreeview:release-focus") 706 | end 707 | end, 708 | 709 | ["todotreeview:filter-notes"] = function() 710 | local todo_view_focus = core.active_view:is(TodoTreeView) 711 | local previous_filter = view.filter 712 | local submit = function(text) 713 | view.filter = text 714 | if todo_view_focus then 715 | view.focus_index = 0 716 | view.hovered_item = view:get_item_by_index(view.focus_index) 717 | view:update_scroll_position() 718 | end 719 | end 720 | local suggest = function(text) 721 | view.filter = text 722 | end 723 | local cancel = function(explicit) 724 | view.filter = previous_filter 725 | end 726 | core.command_view:enter("Filter Notes", { 727 | text = view.filter, 728 | submit = submit, 729 | suggest = suggest, 730 | cancel = cancel 731 | }) 732 | end, 733 | }) 734 | 735 | command.add( 736 | function() 737 | return core.active_view:is(TodoTreeView) 738 | end, { 739 | ["todotreeview:previous"] = function() 740 | if view.focus_index > 0 then 741 | view.focus_index = view.focus_index - 1 742 | view.hovered_item = view:get_item_by_index(view.focus_index) 743 | view:update_scroll_position() 744 | end 745 | end, 746 | 747 | ["todotreeview:next"] = function() 748 | local next_index = view.focus_index + 1 749 | local next_item = view:get_item_by_index(next_index) 750 | if next_item then 751 | view.focus_index = next_index 752 | view.hovered_item = next_item 753 | view:update_scroll_position() 754 | end 755 | end, 756 | 757 | ["todotreeview:collapse"] = function() 758 | if not view.hovered_item then 759 | return 760 | end 761 | 762 | if view.hovered_item.type == "file" then 763 | view.hovered_item.expanded = false 764 | else 765 | if view.hovered_item.type == "group" and view.hovered_item.expanded then 766 | view.hovered_item.expanded = false 767 | else 768 | if config.plugins.todotreeview.todo_mode == "file_tag" then 769 | view.hovered_item, view.focus_index = view:get_hovered_parent_file_tag() 770 | else 771 | view.hovered_item, view.focus_index = view:get_hovered_parent() 772 | end 773 | 774 | view:update_scroll_position() 775 | end 776 | end 777 | end, 778 | 779 | ["todotreeview:expand"] = function() 780 | if not view.hovered_item then 781 | return 782 | end 783 | 784 | if view.hovered_item.type == "file" or view.hovered_item.type == "group" then 785 | if view.hovered_item.expanded then 786 | command.perform("todotreeview:next") 787 | else 788 | view.hovered_item.expanded = true 789 | end 790 | end 791 | end, 792 | 793 | ["todotreeview:open"] = function() 794 | if not view.hovered_item then 795 | return 796 | end 797 | 798 | view:goto_hovered_item() 799 | view.hovered_item = nil 800 | end, 801 | 802 | ["todotreeview:release-focus"] = function() 803 | core.set_active_view( 804 | previous_view or core.root_view:get_primary_node().active_view 805 | ) 806 | view.hovered_item = nil 807 | end, 808 | }) 809 | 810 | keymap.add { ["ctrl+shift+t"] = "todotreeview:toggle" } 811 | keymap.add { ["ctrl+shift+e"] = "todotreeview:expand-items" } 812 | keymap.add { ["ctrl+shift+h"] = "todotreeview:hide-items" } 813 | keymap.add { ["ctrl+shift+b"] = "todotreeview:filter-notes" } 814 | keymap.add { ["up"] = "todotreeview:previous" } 815 | keymap.add { ["down"] = "todotreeview:next" } 816 | keymap.add { ["left"] = "todotreeview:collapse" } 817 | keymap.add { ["right"] = "todotreeview:expand" } 818 | keymap.add { ["return"] = "todotreeview:open" } 819 | keymap.add { ["escape"] = "todotreeview:release-focus" } 820 | 821 | --------------------------------------------------------------------------------