diff --git a/lua/orgmode/agenda/agenda_item.lua b/lua/orgmode/agenda/agenda_item.lua index 7a8da25f0..ef07987bc 100644 --- a/lua/orgmode/agenda/agenda_item.lua +++ b/lua/orgmode/agenda/agenda_item.lua @@ -20,13 +20,13 @@ end ---@field is_in_date_range boolean ---@field date_range_days number ---@field label string ----@field highlights table[] local AgendaItem = {} ---@param headline_date OrgDate single date in a headline ---@param headline OrgHeadline ---@param date OrgDate date for which item should be rendered ---@param index? number +---@return OrgAgendaItem function AgendaItem:new(headline_date, headline, date, index) local opts = {} opts.headline_date = headline_date @@ -45,7 +45,6 @@ function AgendaItem:new(headline_date, headline, date, index) opts.is_in_date_range = headline_date:is_none() and headline_date:is_in_date_range(date) opts.date_range_days = headline_date:get_date_range_days() opts.label = '' - opts.highlights = {} if opts.repeats_on_date then opts.real_date = opts.headline_date:apply_repeater_until(opts.date) end @@ -77,13 +76,6 @@ end function AgendaItem:_generate_data() self.label = self:_generate_label() - self.highlights = {} - local highlight = self:_generate_highlight() - if highlight then - table.insert(self.highlights, highlight) - end - self:_add_keyword_highlight() - self:_add_priority_highlight() end function AgendaItem:_is_valid_for_today() @@ -220,63 +212,48 @@ function AgendaItem:_format_time(date) return formatted_time end -function AgendaItem:_generate_highlight() +---@return string | nil +function AgendaItem:get_hlgroup() if self.headline_date:is_deadline() then if self.headline:is_done() then - return { hlgroup = hl_map.ok } + return hl_map.ok end if self.is_today and self.headline_date:is_after(self.date, 'day') then local diff = math.abs(self.date:diff(self.headline_date)) if diff <= FUTURE_DEADLINE_AS_WARNING_DAYS then - return { hlgroup = hl_map.warning } + return hl_map.warning end return nil end - return { hlgroup = hl_map.deadline } + return hl_map.deadline end if self.headline_date:is_scheduled() then if self.headline_date:is_past('day') and not self.headline:is_done() then - return { hlgroup = hl_map.warning } + return hl_map.warning end - return { hlgroup = hl_map.ok } + return hl_map.ok end return nil end -function AgendaItem:_add_keyword_highlight() +function AgendaItem:get_todo_hlgroup() local todo_keyword, _, type = self.headline:get_todo() if not todo_keyword then return end - local hlgroup = hl_map[todo_keyword] or hl_map[type] - if hlgroup then - table.insert(self.highlights, { - hlgroup = hlgroup, - todo_keyword = todo_keyword, - }) - end + return hl_map[todo_keyword] or hl_map[type], todo_keyword end -function AgendaItem:_add_priority_highlight() +function AgendaItem:get_priority_hlgroup() local priority, priority_node = self.headline:get_priority() if not priority_node then return end - local hlgroup = hl_map.priority[priority].hl_group - local last_hl = self.highlights[#self.highlights] - local start_col = 2 - if last_hl and last_hl.todo_keyword then - start_col = start_col + last_hl.todo_keyword:len() - end - table.insert(self.highlights, { - hlgroup = hlgroup, - priority = priority, - start_col = start_col, - }) + return hl_map.priority[priority].hl_group, priority end return AgendaItem diff --git a/lua/orgmode/agenda/filter.lua b/lua/orgmode/agenda/filter.lua index 5759ebbac..f8d38d746 100644 --- a/lua/orgmode/agenda/filter.lua +++ b/lua/orgmode/agenda/filter.lua @@ -1,27 +1,19 @@ -local utils = require('orgmode.utils') ---@class OrgAgendaFilter ---@field value string ----@field available_tags table ----@field available_categories table ----@field filter_type 'include' | 'exclude' ----@field tags table[] ----@field categories table[] +---@field available_values table +---@field values table[] ---@field term string ---@field parsed boolean ----@field applying boolean local AgendaFilter = {} +---@return OrgAgendaFilter function AgendaFilter:new() local data = { value = '', - available_tags = {}, - available_categories = {}, - filter_type = 'exclude', - tags = {}, - categories = {}, + available_values = {}, + values = {}, term = '', parsed = false, - applying = false, } setmetatable(data, self) self.__index = self @@ -40,90 +32,37 @@ function AgendaFilter:matches(headline) return true end local term_match = vim.trim(self.term) == '' - local tag_cat_match_empty = #self.tags == 0 and #self.categories == 0 + local values_match_empty = #self.values == 0 if not term_match then local rgx = vim.regex(self.term) --[[@as vim.regex]] term_match = rgx:match_str(headline:get_title()) and true or false end - if tag_cat_match_empty then + if values_match_empty then return term_match end - local tag_cat_match = false - - if self.filter_type == 'include' then - tag_cat_match = self:_matches_include(headline) - else - tag_cat_match = self:_matches_exclude(headline) - end + local tag_cat_match = self:_match(headline) return tag_cat_match and term_match end ----@param headline OrgHeadline ---@private -function AgendaFilter:_matches_exclude(headline) - for _, tag in ipairs(self.tags) do - if headline:has_tag(tag.value) then - return false - end - end - - for _, category in ipairs(self.categories) do - if headline:matches_category(category.value) then - return false - end - end - - return true -end - ---@param headline OrgHeadline ----@private -function AgendaFilter:_matches_include(headline) - local tags_to_check = {} - local categories_to_check = {} - - for _, tag in ipairs(self.tags) do - if tag.operator == '-' then - if headline:has_tag(tag.value) then - return false - end - else - table.insert(tags_to_check, tag.value) - end - end - - for _, category in ipairs(self.categories) do - if category.operator == '-' then - if headline:matches_category(category.value) then +---@return boolean +function AgendaFilter:_match(headline) + for _, value in ipairs(self.values) do + if value.operator == '-' then + if headline:has_tag(value.value) or headline:matches_category(value.value) then return false end - else - table.insert(categories_to_check, category.value) - end - end - - local tags_passed = #tags_to_check == 0 - local categories_passed = #categories_to_check == 0 - - for _, category in ipairs(categories_to_check) do - if headline:matches_category(category) then - categories_passed = true - break - end - end - - for _, tag in ipairs(tags_to_check) do - if headline:has_tag(tag) then - tags_passed = true - break + elseif not headline:has_tag(value.value) and not headline:matches_category(value.value) then + return false end end - return tags_passed and categories_passed + return true end ---@param filter string @@ -131,8 +70,7 @@ end function AgendaFilter:parse(filter, skip_check) filter = filter or '' self.value = filter - self.tags = {} - self.categories = {} + self.values = {} local search_rgx = '/[^/]*/?' local search_term = filter:match(search_rgx) if search_term then @@ -140,56 +78,46 @@ function AgendaFilter:parse(filter, skip_check) end filter = filter:gsub(search_rgx, '') for operator, tag_cat in string.gmatch(filter, '([%+%-]*)([^%-%+]+)') do - if not operator or operator == '' or operator == '+' then - self.filter_type = 'include' - end local val = vim.trim(tag_cat) if val ~= '' then - if self.available_tags[val] or skip_check then - table.insert(self.tags, { operator = operator, value = val }) - elseif self.available_categories[val] or skip_check then - table.insert(self.categories, { operator = operator, value = val }) + if self.available_values[val] or skip_check then + table.insert(self.values, { operator = operator, value = val }) end end end self.term = search_term or '' - self.applying = true - if skip_check then - self.parsed = true - end + return self end function AgendaFilter:reset() self.value = '' self.term = '' self.parsed = false - self.applying = false end ----@param content table[] -function AgendaFilter:parse_tags_and_categories(content) +---@param agenda_views OrgAgendaViewType[] +function AgendaFilter:parse_available_filters(agenda_views) if self.parsed then return end - local tags = {} - local categories = {} - for _, item in ipairs(content) do - if item.jumpable and item.headline then - categories[item.headline:get_category():lower()] = true - for _, tag in ipairs(item.headline:get_tags()) do - tags[tag:lower()] = true + local values = {} + for _, agenda_view in ipairs(agenda_views) do + for _, line in ipairs(agenda_view:get_lines()) do + if line.headline then + values[line.headline:get_category()] = true + for _, tag in ipairs(line.headline:get_tags()) do + values[tag] = true + end end end end - self.available_tags = tags - self.available_categories = categories + self.available_values = values self.parsed = true end ---@return string[] function AgendaFilter:get_completion_list() - local list = vim.tbl_keys(self.available_tags) - return utils.concat(list, vim.tbl_keys(self.available_categories), true) + return vim.tbl_keys(self.available_values) end return AgendaFilter diff --git a/lua/orgmode/agenda/init.lua b/lua/orgmode/agenda/init.lua index 680c97915..261a1cc5c 100644 --- a/lua/orgmode/agenda/init.lua +++ b/lua/orgmode/agenda/init.lua @@ -4,17 +4,13 @@ local config = require('orgmode.config') local colors = require('orgmode.colors') local Calendar = require('orgmode.objects.calendar') local AgendaFilter = require('orgmode.agenda.filter') -local AgendaSearchView = require('orgmode.agenda.views.search') -local AgendaTodosView = require('orgmode.agenda.views.todos') -local AgendaTagsView = require('orgmode.agenda.views.tags') -local AgendaView = require('orgmode.agenda.views.agenda') local Menu = require('orgmode.ui.menu') local Promise = require('orgmode.utils.promise') +local AgendaTypes = require('orgmode.agenda.types') ---@class OrgAgenda ----@field content table[] ---@field highlights table[] ----@field views table[] +---@field views OrgAgendaViewType[] ---@field filters OrgAgendaFilter ---@field files OrgFiles local Agenda = {} @@ -34,43 +30,69 @@ function Agenda:new(opts) return data end ----@param View table ----@param type string +---@param type OrgAgendaTypes ---@param opts? table -function Agenda:open_agenda_view(View, type, opts) - self:open_window() - local view = View:new(vim.tbl_deep_extend('force', opts or {}, { - filters = self.filters, +function Agenda:open_view(type, opts) + self.filters:reset() + local view_opts = vim.tbl_extend('force', opts or {}, { files = self.files, - })):build() + agenda_filter = self.filters, + }) + + local view = AgendaTypes[type]:new(view_opts) + if not view then + return + end self.views = { view } - vim.b.org_agenda_type = type - return self:_render() + return self:render() +end + +function Agenda:render() + local line = vim.fn.line('.') + local bufnr = self:_open_window() + for i, view in ipairs(self.views) do + view:render(bufnr, line) + if #self.views > 1 and i < #self.views then + colors.add_hr(bufnr, vim.fn.line('$')) + end + end + vim.bo[bufnr].modifiable = false + + if vim.w.org_window_split_mode == 'horizontal' then + local win_height = math.max(math.min(34, vim.api.nvim_buf_line_count(bufnr)), config.org_agenda_min_height) + if vim.w.org_window_pos and vim.deep_equal(vim.fn.win_screenpos(0), vim.w.org_window_pos) then + vim.cmd(string.format('resize %d', win_height)) + vim.w.org_window_pos = vim.fn.win_screenpos(0) + else + vim.w.org_window_pos = nil + end + end end function Agenda:agenda(opts) - self:open_agenda_view(AgendaView, 'agenda', opts) + return self:open_view('agenda', opts) end -- TODO: Introduce searching ALL/DONE -function Agenda:todos() - self:open_agenda_view(AgendaTodosView, 'todos') +function Agenda:todos(opts) + return self:open_view('todo', opts) end function Agenda:search() - self:open_agenda_view(AgendaSearchView, 'search') + return self:open_view('search') end function Agenda:tags(opts) - self:open_agenda_view(AgendaTagsView, 'tags', opts) + return self:open_view('tags', opts) end -function Agenda:tags_todo() - return self:tags({ todo_only = true }) +function Agenda:tags_todo(opts) + return self:open_view('tags_todo', opts) end +---@private ---@return number buffer number -function Agenda:open_window() +function Agenda:_open_window() -- if an agenda window is already open, return it for _, win in ipairs(vim.api.nvim_list_wins()) do local buf = vim.api.nvim_win_get_buf(win) @@ -78,6 +100,9 @@ function Agenda:open_window() buf = buf, }) if ft == 'orgagenda' then + vim.bo[buf].modifiable = true + colors.highlight({}, true, buf) + vim.api.nvim_buf_set_lines(buf, 0, -1, true, {}) return buf end end @@ -92,7 +117,6 @@ function Agenda:open_window() end function Agenda:prompt() - self.filters:reset() local menu = Menu:new({ title = 'Press key for an agenda command', prompt = 'Press key for an agenda command', @@ -123,7 +147,7 @@ function Agenda:prompt() label = 'Like m, but only TODO entries', key = 'M', action = function() - return self:tags({ todo_only = true }) + return self:tags_todo() end, }) menu:add_option({ @@ -139,56 +163,20 @@ function Agenda:prompt() return menu:open() end -function Agenda:_render(skip_rebuild) - if not skip_rebuild then - self.content = {} - self.highlights = {} - for _, view in ipairs(self.views) do - utils.concat(self.content, view.content) - utils.concat(self.highlights, view.highlights) - end - end - local bufnr = self:open_window() - if vim.w.org_window_split_mode == 'horizontal' then - local win_height = math.max(math.min(34, #self.content), config.org_agenda_min_height) - if vim.w.org_window_pos and vim.deep_equal(vim.fn.win_screenpos(0), vim.w.org_window_pos) then - vim.cmd(string.format('resize %d', win_height)) - vim.w.org_window_pos = vim.fn.win_screenpos(0) - else - vim.w.org_window_pos = nil - end - end - local lines = vim.tbl_map(function(item) - return item.line_content - end, self.content) - vim.bo[bufnr].modifiable = true - vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines) - vim.bo[bufnr].modifiable = false - vim.bo[bufnr].modified = false - colors.highlight(self.highlights, true, bufnr) - vim.tbl_map(function(item) - if item.highlights then - return colors.highlight(item.highlights, false, bufnr) - end - end, self.content) - if not skip_rebuild then - self:_call_view('after_print', self.content) - end -end - function Agenda:reset() return self:_call_view_and_render('reset') end -function Agenda:redo(preserve_cursor_pos) +---@param source? string +function Agenda:redo(source, preserve_cursor_pos) return self.files:load(true):next(vim.schedule_wrap(function() - local cursor_view = nil - if preserve_cursor_pos then - cursor_view = vim.fn.winsaveview() or {} + local save_view = preserve_cursor_pos and vim.fn.winsaveview() + if source == 'mapping' then + self:_call_view_and_render('redo') end - self:_call_view_and_render('build') - if cursor_view then - vim.fn.winrestview(cursor_view) + self:render() + if save_view then + vim.fn.winrestview(save_view) end end)) end @@ -202,7 +190,7 @@ function Agenda:change_span(span) end function Agenda:open_day(day) - return self:open_agenda_view(AgendaView, 'agenda', { + return self:open_view('agenda', { span = 'day', from = day, }) @@ -211,7 +199,8 @@ end function Agenda:goto_date() local views = {} for _, view in ipairs(self.views) do - if view.goto_date then + ---@diagnostic disable-next-line: undefined-field + if view.goto_date and view.view:is_in_range() then table.insert(views, view) end end @@ -227,17 +216,17 @@ function Agenda:goto_date() for _, view in ipairs(views) do view:goto_date(date) end - self:_render() + return self:render() end) end function Agenda:switch_to_item() - local item = self:_get_jumpable_item() + local item = self:_get_headline() if not item then return end - vim.cmd('edit ' .. vim.fn.fnameescape(item.file)) - vim.fn.cursor({ item.file_position, 1 }) + vim.cmd('edit ' .. vim.fn.fnameescape(item.file.filename)) + vim.fn.cursor({ item:get_range().start_line, 1 }) vim.cmd([[normal! zv]]) end @@ -276,7 +265,7 @@ function Agenda:clock_out() getter = function() local last_clocked = self.files:get_clocked_headline() if last_clocked and last_clocked:is_clocked_in() then - return { file = last_clocked.file.filename, file_position = last_clocked:get_range().start_line } + return last_clocked end end, }) @@ -289,7 +278,7 @@ function Agenda:clock_cancel() getter = function() local last_clocked = self.files:get_clocked_headline() if last_clocked and last_clocked:is_clocked_in() then - return { file = last_clocked.file.filename, file_position = last_clocked:get_range().start_line } + return last_clocked end end, }) @@ -357,11 +346,23 @@ end function Agenda:toggle_clock_report() self:_call_view('toggle_clock_report') - return self:redo(true) + return self:redo('agenda', true) +end + +---@private +---@return OrgHeadline | nil, OrgAgendaLine | nil, OrgAgendaViewType | nil +function Agenda:_get_headline() + local line = vim.fn.line('.') + for _, view in ipairs(self.views) do + local agenda_line = view:get_line(line) + if agenda_line and agenda_line.headline then + return agenda_line.headline, agenda_line, view + end + end end function Agenda:goto_item() - local item = self:_get_jumpable_item() + local item = self:_get_headline() if not item then return end @@ -392,43 +393,45 @@ function Agenda:goto_item() vim.cmd([[aboveleft split]]) end - vim.cmd('edit ' .. vim.fn.fnameescape(item.file)) - vim.fn.cursor({ item.file_position, 1 }) + vim.cmd('edit ' .. vim.fn.fnameescape(item.file.filename)) + vim.fn.cursor({ item:get_range().start_line, 1 }) vim.cmd([[normal! zv]]) end function Agenda:filter() local this = self - self.filters:parse_tags_and_categories(self.content) + self.filters:parse_available_filters(self.views) local filter_term = vim.fn.OrgmodeInput('Filter [+cat-tag/regexp/]: ', self.filters.value, function(arg_lead) - return utils.prompt_autocomplete(arg_lead:lower(), this.filters:get_completion_list(), { '+', '-' }) + return utils.prompt_autocomplete(arg_lead, this.filters:get_completion_list(), { '+', '-' }) end) + if filter_term == self.filters.value then + return + end self.filters:parse(filter_term) - return self:redo() + return self:redo('filter', true) end ---@param opts table function Agenda:_remote_edit(opts) opts = opts or {} - local line = vim.fn.line('.') or 0 local action = opts.action if not action then return end local getter = opts.getter or function() - local item = self.content[line] - if not item or not item.jumpable then + local item, agenda_line, view = self:_get_headline() + if not item then return end - return item + return item, agenda_line, view end - local item = getter() + local item, agenda_line, view = getter() if not item then return end - local update = self.files:update_file(item.file, function(_) - vim.fn.cursor({ item.file_position, 1 }) + local update = item.file:update(function(_) + vim.fn.cursor({ item:get_range().start_line, 1 }) return Promise.resolve(require('orgmode').action(action)):next(function() return self.files:get_closest_headline_or_nil() end) @@ -437,61 +440,28 @@ function Agenda:_remote_edit(opts) update:next(function(headline) ---@cast headline OrgHeadline if opts.redo then - return self:redo(true) + return self:redo('remote_edit', true) end if not opts.update_in_place or not headline then return end - local line_range_same = headline:get_range():is_same_line_range(item.headline:get_range()) + local line_range_same = headline:get_range():is_same_line_range(item:get_range()) local update_item_inline = function() - if item.agenda_item then - item.agenda_item:set_headline(headline) - self.content[line] = - AgendaView.build_agenda_item_content(item.agenda_item, item.longest_category, item.longest_date, item.line) - else - self.content[line] = AgendaTodosView.generate_todo_item(headline, item.longest_category, item.line) + if not agenda_line or not view then + return end - return self:_render(true) + return view:rerender_agenda_line(agenda_line, headline) end if line_range_same then return update_item_inline() end - -- If line range was changed, some other agenda items might have outdated position - -- In that case, we need to reload the agenda and try to find the same headline to update it in place - return self:redo(true):next(function() - for content_line, content_item in pairs(self.content) do - if content_item.headline and content_item.headline:is_same(headline) then - item = self.content[content_line] - return update_item_inline() - end - end - end) + return self:redo('remote_edit', true) end) end ----@return table|nil -function Agenda:_get_jumpable_item() - local item = self.content[vim.fn.line('.')] - if not item then - return nil - end - if item.is_table and item.table_row then - for _, view in ipairs(self.views) do - if view.clock_report then - item = view.clock_report:find_agenda_item(item) - break - end - end - end - if not item.jumpable then - return nil - end - return item -end - function Agenda:quit() vim.api.nvim_win_close(0, true) end @@ -499,7 +469,7 @@ end function Agenda:_call_view(method, ...) local executed = false for _, view in ipairs(self.views) do - if view[method] then + if view[method] and view.view:is_in_range() then view[method](view, ...) executed = true end @@ -511,7 +481,7 @@ end function Agenda:_call_view_and_render(method, ...) local executed = self:_call_view(method, ...) if executed then - return self:_render() + return self:render() end end diff --git a/lua/orgmode/agenda/types/agenda.lua b/lua/orgmode/agenda/types/agenda.lua new file mode 100644 index 000000000..6000154fa --- /dev/null +++ b/lua/orgmode/agenda/types/agenda.lua @@ -0,0 +1,470 @@ +local Date = require('orgmode.objects.date') +local config = require('orgmode.config') +local AgendaFilter = require('orgmode.agenda.filter') +local AgendaItem = require('orgmode.agenda.agenda_item') +local AgendaView = require('orgmode.agenda.view.init') +local AgendaLine = require('orgmode.agenda.view.line') +local AgendaLineToken = require('orgmode.agenda.view.token') +local ClockReport = require('orgmode.clock.report') +local utils = require('orgmode.utils') + +---@class OrgAgendaViewType +---@field render fun(self: OrgAgendaViewType, bufnr:number, current_line?: number): OrgAgendaView +---@field get_lines fun(self: OrgAgendaViewType): OrgAgendaLine | OrgAgendaLine[] +---@field get_line fun(self: OrgAgendaViewType, line_nr: number): OrgAgendaLine | nil +---@field rerender_agenda_line fun(self: OrgAgendaViewType, agenda_line: OrgAgendaLine, headline: OrgHeadline): OrgAgendaLine | nil +---@field view OrgAgendaView + +---@class OrgAgendaTypeOpts +---@field files OrgFiles +---@field agenda_filter OrgAgendaFilter +---@field filter? string +---@field span? OrgAgendaSpan +---@field from? OrgDate +---@field start_on_weekday? number +---@field start_day? string +---@field header? string +---@field show_clock_report? boolean +---@field is_custom? boolean + +---@class OrgAgendaType:OrgAgendaViewType +---@field files OrgFiles +---@field agenda_filter OrgAgendaFilter +---@field filter? OrgAgendaFilter +---@field span? OrgAgendaSpan +---@field from? OrgDate +---@field to? OrgDate +---@field bufnr? number +---@field start_on_weekday? number +---@field start_day? string +---@field header? string +---@field show_clock_report? boolean +---@field clock_report? OrgClockReport +---@field clock_report_view? OrgAgendaView +---@field is_custom? boolean +local OrgAgendaType = {} +OrgAgendaType.__index = OrgAgendaType + +---@param opts OrgAgendaTypeOpts +function OrgAgendaType:new(opts) + local data = { + files = opts.files, + agenda_filter = opts.agenda_filter, + filter = opts.filter and AgendaFilter:new():parse(opts.filter, true) or nil, + span = opts.span or config:get_agenda_span(), + from = opts.from or Date.now():start_of('day'), + to = nil, + clock_report = nil, + show_clock_report = opts.show_clock_report or false, + start_on_weekday = opts.start_on_weekday or config.org_agenda_start_on_weekday, + start_day = opts.start_day or config.org_agenda_start_day, + header = opts.header, + is_custom = opts.is_custom or false, + } + local this = setmetatable(data, OrgAgendaType) + this:_set_date_range() + return this +end + +function OrgAgendaType:advance_span(count, direction) + count = count or 1 + direction = direction * count + local action = { [self.span] = direction } + if type(self.span) == 'number' then + action = { day = self.span * direction } + end + self.from = self.from:add(action) + self.to = self.to:add(action) + return self +end + +function OrgAgendaType:change_span(span) + if span == self.span then + return + end + if span == 'year' then + local c = vim.fn.confirm('Are you sure you want to print agenda for the whole year?', '&Yes\n&No') + if c ~= 1 then + return + end + end + self.span = span + self:_set_date_range() + return self +end + +function OrgAgendaType:_jump_to_date(date) + for _, line in ipairs(self.view.lines) do + if line.metadata.agenda_day and line.metadata.agenda_day:is_same(date, 'day') then + return vim.fn.cursor({ line.line_nr, 0 }) + end + end +end + +function OrgAgendaType:goto_date(date) + self.to = nil + self:_set_date_range(date) + local was_line_in_view = self.view:is_in_range(vim.fn.line('.')) + self.after_render = function() + if was_line_in_view then + self:_jump_to_date(date) + end + end +end + +function OrgAgendaType:reset() + return self:goto_date(Date.now():start_of('day')) +end + +---@return OrgAgendaLine[] +function OrgAgendaType:get_lines() + return self.view.lines +end + +---@param row number +---@return OrgAgendaLine | nil +function OrgAgendaType:get_line(row) + return utils.find(self.view.lines, function(line) + return line.line_nr == row + end) +end + +---@private +---@param line? number +---@return OrgDate +function OrgAgendaType:_get_jump_to_date(line) + line = line or vim.fn.line('.') + if not self.view then + return Date.now():start_of('day') + end + if self.span == 'day' then + return self.from + end + + local agenda_line = self:get_line(line) + if not agenda_line then + return self.from + end + + ---@type OrgDate + local agenda_line_date = agenda_line.metadata.agenda_day + or (agenda_line.metadata.agenda_item and agenda_line.metadata.agenda_item.date) + + if not agenda_line_date then + return self.from + end + + if self.span == 'week' then + local range = self.from:get_range_until(self.to) + for _, date in ipairs(range) do + if date:get_isoweekday() == agenda_line_date:get_isoweekday() then + return date + end + end + end + + if self.span == 'month' then + return self.from:set({ day = agenda_line_date.day }) + end + + if self.span == 'year' then + return self.from:set({ day = agenda_line_date.day, month = agenda_line_date.month }) + end + + return self.from +end + +---@param bufnr? number +---@param current_line? number +function OrgAgendaType:render(bufnr, current_line) + self.bufnr = bufnr or 0 + local jump_to_date = self.from + local was_line_in_view = true + if self.view then + was_line_in_view = self.view:is_in_range(current_line) + end + if was_line_in_view then + jump_to_date = self:_get_jump_to_date(current_line) + end + local agenda_days = self:_get_agenda_days() + + local agendaView = AgendaView:new({ bufnr = self.bufnr }) + agendaView:add_line(AgendaLine:single_token({ + content = self:_get_title(), + hl_group = '@org.agenda.header', + })) + + for _, agenda_day in ipairs(agenda_days) do + local is_today = agenda_day.day:is_today() + local is_weekend = agenda_day.day:is_weekend() + local add_highlight = is_today or is_weekend + + agendaView:add_line(AgendaLine:single_token({ + content = self:_format_day(agenda_day.day), + hl_group = add_highlight and (is_today and '@org.agenda.today' or '@org.agenda.weekend') or nil, + }, { + metadata = { + agenda_day = agenda_day.day, + }, + })) + + for _, agenda_item in ipairs(agenda_day.agenda_items) do + agendaView:add_line(self:_build_line(agenda_item, agenda_day)) + end + end + + if self.show_clock_report then + agendaView:add_line(AgendaLine:single_token({ + content = '', + })) + local clock_report = ClockReport:new({ + from = self.from, + to = self.to, + files = self.files, + }):get_table_report(agendaView.lines[#agendaView.lines].line_nr) + + for _, row in ipairs(clock_report.rows) do + local line = AgendaLine:new({ + separator = '|', + }) + for i, cell in ipairs(row.cells) do + if i == 1 then + line:add_token(AgendaLineToken:new({ + content = '', + hl_group = '@org.bold', + })) + end + local hl_group = '@org.bold' + if cell.reference then + line.headline = cell.reference + hl_group = '@org.hyperlink' + end + line:add_token(AgendaLineToken:new({ + content = cell.content, + hl_group = hl_group, + trim_for_hl = true, + })) + end + line:add_token(AgendaLineToken:new({ + content = '', + hl_group = '@org.bold', + })) + agendaView:add_line(line) + end + end + + self.view = agendaView:render() + if self.after_render then + self.after_render() + self.after_render = nil + elseif was_line_in_view then + self:_jump_to_date(jump_to_date) + end + return self.view +end + +---@private +---@param agenda_item OrgAgendaItem +---@param metadata table +---@return OrgAgendaLine +function OrgAgendaType:_build_line(agenda_item, metadata) + local headline = agenda_item.headline + local item_hl_group = agenda_item:get_hlgroup() + local line = AgendaLine:new({ + hl_group = item_hl_group, + line_hl_group = headline:is_clocked_in() and 'Visual' or nil, + headline = headline, + metadata = { + agenda_item = agenda_item, + category_length = metadata.category_length, + label_length = metadata.label_length, + }, + }) + line:add_token(AgendaLineToken:new({ + content = ' ' .. utils.pad_right(('%s:'):format(headline:get_category()), metadata.category_length), + })) + line:add_token(AgendaLineToken:new({ + content = utils.pad_right(agenda_item.label, metadata.label_length), + })) + local todo = headline:get_todo() + if todo then + local todo_hl_group = agenda_item:get_todo_hlgroup() + line:add_token(AgendaLineToken:new({ + content = todo, + hl_group = todo_hl_group, + })) + end + local priority = headline:get_priority() + if priority ~= '' then + local priority_hl_group = agenda_item:get_priority_hlgroup() + line:add_token(AgendaLineToken:new({ + content = ('[#%s]'):format(tostring(priority)), + hl_group = priority_hl_group, + })) + end + line:add_token(AgendaLineToken:new({ + content = headline:get_title(), + })) + if #headline:get_tags() > 0 then + local tags_string = headline:tags_to_string() + line:add_token(AgendaLineToken:new({ + content = tags_string, + virt_text_pos = 'right_align', + hl_group = '@org.agenda.tag', + })) + end + + return line +end + +---@param agenda_line OrgAgendaLine +---@param headline OrgHeadline +function OrgAgendaType:rerender_agenda_line(agenda_line, headline) + agenda_line.metadata.agenda_item:set_headline(headline) + local line = self:_build_line(agenda_line.metadata.agenda_item, agenda_line.metadata) + self.view:replace_line(agenda_line, line) +end + +---@return { day: OrgDate, agenda_items: OrgAgendaItem[], category_length: number, label_length: 0 }[] +function OrgAgendaType:_get_agenda_days() + local dates = self.from:get_range_until(self.to) + local agenda_days = {} + + local headline_dates = {} + for _, orgfile in ipairs(self.files:all()) do + for _, headline in ipairs(orgfile:get_opened_headlines()) do + for _, headline_date in ipairs(headline:get_valid_dates_for_agenda()) do + table.insert(headline_dates, { + headline_date = headline_date, + headline = headline, + }) + end + end + end + + local headlines = {} + for _, day in ipairs(dates) do + local date = { day = day, agenda_items = {}, category_length = 0, label_length = 0 } + + for index, item in ipairs(headline_dates) do + local headline = item.headline + local agenda_item = AgendaItem:new(item.headline_date, headline, day, index) + if + agenda_item.is_valid + and self.agenda_filter:matches(headline) + and (not self.filter or self.filter:matches(headline)) + then + table.insert(headlines, headline) + table.insert(date.agenda_items, agenda_item) + date.category_length = math.max(date.category_length, vim.api.nvim_strwidth(headline:get_category())) + date.label_length = math.max(date.label_length, vim.api.nvim_strwidth(agenda_item.label)) + end + end + + date.agenda_items = self._sort(date.agenda_items) + date.category_length = math.max(11, date.category_length + 1) + date.label_length = math.min(11, date.label_length) + + table.insert(agenda_days, date) + end + + return agenda_days +end + +function OrgAgendaType:toggle_clock_report() + self.show_clock_report = not self.show_clock_report + return self +end + +function OrgAgendaType:_set_date_range(from) + local span = self.span + from = from or self.from + local is_week = span == 'week' or span == '7' + if is_week and self.start_on_weekday then + from = from:set_isoweekday(self.start_on_weekday) + end + + local to = nil + local modifier = { [span] = 1 } + if type(span) == 'number' then + modifier = { day = span } + end + + to = from:add(modifier) + + if self.start_day and type(self.start_day) == 'string' then + from = from:adjust(self.start_day) + to = to:adjust(self.start_day) + end + + self.span = span + self.from = from + self.to = to +end + +---@return string +function OrgAgendaType:_get_title() + if self.header ~= nil then + return self.header + end + local span = self.span + if type(span) == 'number' then + span = string.format('%d days', span) + end + local span_number = '' + if span == 'week' then + span_number = string.format(' (W%s)', self.from:get_week_number()) + end + return utils.capitalize(span) .. '-agenda' .. span_number .. ':' +end + +function OrgAgendaType:_format_day(day) + return string.format('%-10s %s', day:format('%A'), day:format('%d %B %Y')) +end + +local function sort_by_date_or_priority_or_category(a, b) + if a.headline:get_priority_sort_value() ~= b.headline:get_priority_sort_value() then + return a.headline:get_priority_sort_value() > b.headline:get_priority_sort_value() + end + if not a.real_date:is_same(b.real_date, 'day') then + return a.real_date:is_before(b.real_date) + end + return a.index < b.index +end + +---@private +---@param agenda_items OrgAgendaItem[] +---@return OrgAgendaItem[] +function OrgAgendaType._sort(agenda_items) + table.sort(agenda_items, function(a, b) + if a.is_same_day and b.is_same_day then + if a.real_date:has_time() and not b.real_date:has_time() then + return true + end + if b.real_date:has_time() and not a.real_date:has_time() then + return false + end + if a.real_date:has_time() and b.real_date:has_time() then + return a.real_date:is_before(b.real_date) + end + return sort_by_date_or_priority_or_category(a, b) + end + + if a.is_same_day and not b.is_same_day then + if a.real_date:has_time() or (b.real_date:is_none() and not a.real_date:is_none()) then + return true + end + end + + if not a.is_same_day and b.is_same_day then + if b.real_date:has_time() or (a.real_date:is_none() and not b.real_date:is_none()) then + return false + end + end + + return sort_by_date_or_priority_or_category(a, b) + end) + return agenda_items +end + +return OrgAgendaType diff --git a/lua/orgmode/agenda/types/init.lua b/lua/orgmode/agenda/types/init.lua new file mode 100644 index 000000000..e1caf3770 --- /dev/null +++ b/lua/orgmode/agenda/types/init.lua @@ -0,0 +1,8 @@ +---@alias OrgAgendaTypes 'agenda' | 'todo' | 'tags' | 'tags_todo' | 'search' +return { + agenda = require('orgmode.agenda.types.agenda'), + todo = require('orgmode.agenda.types.todo'), + tags = require('orgmode.agenda.types.tags'), + tags_todo = require('orgmode.agenda.types.tags_todo'), + search = require('orgmode.agenda.types.search'), +} diff --git a/lua/orgmode/agenda/types/search.lua b/lua/orgmode/agenda/types/search.lua new file mode 100644 index 000000000..71381d532 --- /dev/null +++ b/lua/orgmode/agenda/types/search.lua @@ -0,0 +1,43 @@ +---@diagnostic disable: inject-field +local OrgAgendaTodosType = require('orgmode.agenda.types.todo') + +---@class OrgAgendaSearchTypeOpts:OrgAgendaTodosTypeOpts +---@field headline_search? string + +---@class OrgAgendaSearchType:OrgAgendaTodosType +---@field headline_search? string +local OrgAgendaSearchType = {} +OrgAgendaSearchType.__index = OrgAgendaSearchType + +---@param opts OrgAgendaSearchTypeOpts +function OrgAgendaSearchType:new(opts) + opts.todo_only = false + opts.subheader = 'Press "r" to update search' + setmetatable(self, { __index = OrgAgendaTodosType }) + local obj = OrgAgendaTodosType:new(opts) + setmetatable(obj, self) + obj.headline_search = self.headline_search + if not opts.headline_search or opts.headline_search == '' then + obj.headline_search = self:get_search_term() + end + return obj +end + +function OrgAgendaSearchType:get_file_headlines(file) + return file:find_headlines_matching_search_term(self.headline_search or '', false, true) +end + +function OrgAgendaSearchType:get_search_term() + return vim.fn.OrgmodeInput('Enter search term: ', self.headline_search or '') +end + +function OrgAgendaSearchType:redo() + -- Skip prompt for custom views + if self.is_custom then + return self + end + self.headline_search = self:get_search_term() + return self +end + +return OrgAgendaSearchType diff --git a/lua/orgmode/agenda/types/tags.lua b/lua/orgmode/agenda/types/tags.lua new file mode 100644 index 000000000..b686226d1 --- /dev/null +++ b/lua/orgmode/agenda/types/tags.lua @@ -0,0 +1,57 @@ +---@diagnostic disable: inject-field +local utils = require('orgmode.utils') +local Search = require('orgmode.files.elements.search') +local OrgAgendaTodosType = require('orgmode.agenda.types.todo') + +---@class OrgAgendaTagsTypeOpts:OrgAgendaTodosTypeOpts +---@field search_query? string + +---@class OrgAgendaTagsType:OrgAgendaTodosType +local OrgAgendaTagsType = {} +OrgAgendaTagsType.__index = OrgAgendaTagsType + +---@param opts OrgAgendaTagsTypeOpts +function OrgAgendaTagsType:new(opts) + opts.todo_only = opts.todo_only or false + opts.subheader = 'Press "r" to update search' + local search_query = opts.search_query + if not search_query or search_query == '' then + search_query = self:get_tags(opts.files) + if not search_query then + return nil + end + end + + setmetatable(self, { __index = OrgAgendaTodosType }) + local obj = OrgAgendaTodosType:new(opts) + setmetatable(obj, self) + obj.search_query = search_query + obj.header = 'Headlines with TAGS match: ' .. obj.search_query + return obj +end + +function OrgAgendaTagsType:get_file_headlines(file) + return file:apply_search(Search:new(self.search_query or ''), self.todo_only) +end + +---@param files? OrgFiles +function OrgAgendaTagsType:get_tags(files) + local tags = vim.fn.OrgmodeInput('Match: ', self.search_query or '', function(arg_lead) + return utils.prompt_autocomplete(arg_lead, (files or self.files):get_tags()) + end) + if vim.trim(tags) == '' then + return utils.echo_warning('Invalid tag.') + end + return tags +end + +function OrgAgendaTagsType:redo() + if self.is_custom then + return self + end + self.search_query = self:get_tags() or '' + self.header = 'Headlines with TAGS match: ' .. self.search_query + return self +end + +return OrgAgendaTagsType diff --git a/lua/orgmode/agenda/types/tags_todo.lua b/lua/orgmode/agenda/types/tags_todo.lua new file mode 100644 index 000000000..e38d32ef8 --- /dev/null +++ b/lua/orgmode/agenda/types/tags_todo.lua @@ -0,0 +1,19 @@ +local OrgAgendaTagsType = require('orgmode.agenda.types.tags') + +---@class OrgAgendaTagsTodoType:OrgAgendaTagsType +local OrgAgendaTagsTodoType = {} +OrgAgendaTagsTodoType.__index = OrgAgendaTagsTodoType + +---@param opts OrgAgendaTagsTypeOpts +function OrgAgendaTagsTodoType:new(opts) + opts.todo_only = true + setmetatable(self, { __index = OrgAgendaTagsType }) + local obj = OrgAgendaTagsType:new(opts) + if not obj then + return nil + end + setmetatable(obj, self) + return obj +end + +return OrgAgendaTagsTodoType diff --git a/lua/orgmode/agenda/types/todo.lua b/lua/orgmode/agenda/types/todo.lua new file mode 100644 index 000000000..707a53e21 --- /dev/null +++ b/lua/orgmode/agenda/types/todo.lua @@ -0,0 +1,171 @@ +local AgendaView = require('orgmode.agenda.view.init') +local AgendaLine = require('orgmode.agenda.view.line') +local AgendaFilter = require('orgmode.agenda.filter') +local AgendaLineToken = require('orgmode.agenda.view.token') +local utils = require('orgmode.utils') +local agenda_highlights = require('orgmode.colors.highlights') +local hl_map = agenda_highlights.get_agenda_hl_map() + +---@class OrgAgendaTodosTypeOpts +---@field files OrgFiles +---@field agenda_filter OrgAgendaFilter +---@field filter? string +---@field header? string +---@field subheader? string +---@field todo_only? boolean +---@field is_custom? boolean + +---@class OrgAgendaTodosType:OrgAgendaViewType +---@field files OrgFiles +---@field agenda_filter OrgAgendaFilter +---@field filter? OrgAgendaFilter +---@field header? string +---@field subheader? string +---@field bufnr? number +---@field todo_only? boolean +---@field is_custom? boolean +local OrgAgendaTodosType = {} +OrgAgendaTodosType.__index = OrgAgendaTodosType + +---@param opts OrgAgendaTodosTypeOpts +function OrgAgendaTodosType:new(opts) + return setmetatable({ + files = opts.files, + agenda_filter = opts.agenda_filter, + filter = opts.filter and AgendaFilter:new():parse(opts.filter, true) or nil, + header = opts.header, + subheader = opts.subheader, + todo_only = opts.todo_only == nil and true or opts.todo_only, + is_custom = opts.is_custom or false, + }, OrgAgendaTodosType) +end + +---@param bufnr? number +function OrgAgendaTodosType:render(bufnr) + self.bufnr = bufnr or 0 + local headlines, category_length = self:_get_headlines() + local agendaView = AgendaView:new({ bufnr = self.bufnr }) + + agendaView:add_line(AgendaLine:single_token({ + content = self.header or 'Global list of TODO items of type: ALL', + hl_group = '@org.agenda.header', + })) + agendaView:add_line(AgendaLine:single_token({ + content = self.subheader or '', + hl_group = '@org.agenda.header', + })) + + for _, headline in ipairs(headlines) do + agendaView:add_line(self:_build_line(headline, { category_length = category_length })) + end + + local result = agendaView:render() + self.view = result + return result +end + +---@private +---@param headline OrgHeadline +---@param metadata table +---@return OrgAgendaLine +function OrgAgendaTodosType:_build_line(headline, metadata) + local line = AgendaLine:new({ + headline = headline, + line_hl_group = headline:is_clocked_in() and 'Visual' or nil, + metadata = metadata, + }) + line:add_token(AgendaLineToken:new({ + content = ' ' .. utils.pad_right(('%s:'):format(headline:get_category()), metadata.category_length), + })) + + local todo, _, todo_type = headline:get_todo() + if todo then + line:add_token(AgendaLineToken:new({ + content = todo, + hl_group = hl_map[todo] or hl_map[todo_type], + })) + end + local priority = headline:get_priority() + if priority ~= '' then + line:add_token(AgendaLineToken:new({ + content = ('[#%s]'):format(tostring(priority)), + hl_group = hl_map.priority[priority].hl_group, + })) + end + line:add_token(AgendaLineToken:new({ + content = headline:get_title(), + })) + if #headline:get_tags() > 0 then + local tags_string = headline:tags_to_string() + line:add_token(AgendaLineToken:new({ + content = tags_string, + virt_text_pos = 'right_align', + hl_group = '@org.agenda.tag', + })) + end + return line +end + +---@return OrgAgendaLine[] +function OrgAgendaTodosType:get_lines() + return self.view.lines +end + +---@param row number +---@return OrgAgendaLine | nil +function OrgAgendaTodosType:get_line(row) + return utils.find(self.view.lines, function(line) + return line.line_nr == row + end) +end + +---@param agenda_line OrgAgendaLine +---@param headline OrgHeadline +function OrgAgendaTodosType:rerender_agenda_line(agenda_line, headline) + local line = self:_build_line(headline, agenda_line.metadata) + self.view:replace_line(agenda_line, line) +end + +---@param file OrgFile +---@return OrgHeadline[] +function OrgAgendaTodosType:get_file_headlines(file) + if self.todo_only then + return file:get_unfinished_todo_entries() + end + + return file:get_headlines() +end + +---@return OrgHeadline[], number +function OrgAgendaTodosType:_get_headlines() + local items = {} + local category_length = 0 + + for _, orgfile in ipairs(self.files:all()) do + local headlines = self:get_file_headlines(orgfile) + for _, headline in ipairs(headlines) do + if self.agenda_filter:matches(headline) and (not self.filter or self.filter:matches(headline)) then + category_length = math.max(category_length, vim.api.nvim_strwidth(headline:get_category())) + table.insert(items, headline) + end + end + end + + self:_sort(items) + return items, category_length + 1 +end + +---@private +---@param todos OrgHeadline[] +---@return OrgHeadline[] +function OrgAgendaTodosType:_sort(todos) + table.sort(todos, function(a, b) + if a:get_priority_sort_value() ~= b:get_priority_sort_value() then + return a:get_priority_sort_value() > b:get_priority_sort_value() + end + return a:get_category() < b:get_category() + end) + return todos +end + +return OrgAgendaTodosType diff --git a/lua/orgmode/agenda/view/init.lua b/lua/orgmode/agenda/view/init.lua new file mode 100644 index 000000000..24ca4354b --- /dev/null +++ b/lua/orgmode/agenda/view/init.lua @@ -0,0 +1,74 @@ +local colors = require('orgmode.colors') + +---@class OrgAgendaView +---@field bufnr number +---@field start_line number +---@field line_counter number +---@field lines OrgAgendaLine[] +local OrgAgendaView = {} +OrgAgendaView.__index = OrgAgendaView + +---@param opts { bufnr: number } +---@return OrgAgendaView +function OrgAgendaView:new(opts) + local line_nr = vim.api.nvim_buf_line_count(opts.bufnr) + -- Increase the line if the view is not the first one + -- Since nvim_buf_set_lines overrides line from the previous view + if line_nr > 1 then + line_nr = line_nr + 1 + end + return setmetatable({ + bufnr = opts.bufnr, + start_line = line_nr, + end_line = line_nr, + lines = {}, + }, OrgAgendaView) +end + +---@param line OrgAgendaLine +function OrgAgendaView:add_line(line) + line.line_nr = self.end_line + line.view = self + table.insert(self.lines, line) + self.end_line = self.end_line + 1 +end + +---@param line_nr? number +---@return boolean +function OrgAgendaView:is_in_range(line_nr) + line_nr = line_nr or vim.fn.line('.') + return line_nr >= self.start_line and line_nr <= self.end_line +end + +---@param old_line OrgAgendaLine +---@param new_line OrgAgendaLine +function OrgAgendaView:replace_line(old_line, new_line) + new_line.line_nr = old_line.line_nr + new_line.view = self + for i, line in ipairs(self.lines) do + if line.line_nr == old_line.line_nr then + self.lines[i] = new_line + new_line:render() + return + end + end +end + +function OrgAgendaView:render() + local lines = {} + local highlights = {} + local virt_texts = {} + for _, line in ipairs(self.lines) do + local compiled = line:compile() + table.insert(lines, compiled.content) + vim.list_extend(highlights, compiled.highlights) + vim.list_extend(virt_texts, compiled.virt_texts) + end + vim.api.nvim_buf_set_lines(self.bufnr, self.start_line - 1, self.end_line - 1, false, lines) + colors.highlight(highlights, false, self.bufnr) + colors.virtual_text(virt_texts, self.bufnr) + + return self +end + +return OrgAgendaView diff --git a/lua/orgmode/agenda/view/line.lua b/lua/orgmode/agenda/view/line.lua new file mode 100644 index 000000000..041c06b13 --- /dev/null +++ b/lua/orgmode/agenda/view/line.lua @@ -0,0 +1,127 @@ +local colors = require('orgmode.colors') +local Range = require('orgmode.files.elements.range') +local OrgAgendaLineToken = require('orgmode.agenda.view.token') +---@class OrgAgendaLineOpts +---@field headline? OrgHeadline +---@field hl_group? string Highlight group for the whole line content +---@field line_hl_group? string Highlight group for the whole line (including white space) +---@field metadata? table +---@field separator? string + +---@class OrgAgendaLine:OrgAgendaLineOpts +---@field view OrgAgendaView +---@field line_nr number +---@field col_counter number +---@field headline? OrgHeadline +---@field tokens? OrgAgendaLineToken[] +local OrgAgendaLine = {} +OrgAgendaLine.__index = OrgAgendaLine + +---@param opts? OrgAgendaLineOpts +---@return OrgAgendaLine +function OrgAgendaLine:new(opts) + opts = opts or {} + return setmetatable({ + tokens = {}, + col_counter = 1, + headline = opts.headline, + hl_group = opts.hl_group, + line_hl_group = opts.line_hl_group, + separator = opts.separator or ' ', + metadata = opts.metadata or {}, + }, OrgAgendaLine) +end + +---@param token_opts OrgAgendaLineTokenOpts +---@param line_opts? OrgAgendaLineOpts +---@return OrgAgendaLine +function OrgAgendaLine:single_token(token_opts, line_opts) + local line = OrgAgendaLine:new(line_opts) + line:add_token(OrgAgendaLineToken:new(token_opts)) + return line +end + +---@param token OrgAgendaLineToken +function OrgAgendaLine:add_token(token) + -- Add offset because of the concatenation later + local concat_offset = #self.tokens > 0 and #self.separator or 0 + local length = #token.content + local start_col = self.col_counter + concat_offset + token.range = Range:new({ + start_line = self.line_nr, + start_col = start_col, + end_line = self.line_nr, + end_col = start_col + length, + }) + table.insert(self.tokens, token) + self.col_counter = self.col_counter + length + concat_offset +end + +---@return { content: string, highlights: { hlgroup: string, range: OrgRange }[], virt_texts: { content: string, hl_groups: string[] }[] } +function OrgAgendaLine:compile() + local result = { + content = {}, + highlights = {}, + virt_texts = {}, + } + + if self.hl_group then + local line_range = Range.from_line(self.line_nr) + line_range.end_col = -1 + table.insert(result.highlights, { + hlgroup = self.hl_group, + range = line_range, + }) + end + if self.line_hl_group then + local line_range = Range.from_line(self.line_nr) + line_range.end_col = -1 + table.insert(result.highlights, { + hlgroup = self.line_hl_group, + whole_line = true, + range = line_range, + }) + end + + for _, token in ipairs(self.tokens) do + token.range.start_line = self.line_nr + token.range.end_line = self.line_nr + if token.virt_text_pos then + local hl_groups = { token.hl_group } + if self.hl_group then + table.insert(hl_groups, 1, self.hl_group) + end + table.insert(result.virt_texts, { + content = token.content, + hl_groups = hl_groups, + range = token.range, + virt_text_pos = token.virt_text_pos, + }) + else + table.insert(result.content, token.content) + local hl = token:get_highlights() + if hl then + table.insert(result.highlights, hl) + end + end + end + + return { + content = table.concat(result.content, self.separator), + highlights = result.highlights, + virt_texts = result.virt_texts, + } +end + +function OrgAgendaLine:render() + local compiled = self:compile() + local bufnr = self.view.bufnr + colors.clear_extmarks(bufnr, self.line_nr - 1, self.line_nr - 1) + vim.bo[bufnr].modifiable = true + vim.api.nvim_buf_set_lines(bufnr, self.line_nr - 1, self.line_nr, false, { compiled.content }) + vim.bo[bufnr].modifiable = false + colors.highlight(compiled.highlights, false, bufnr) + colors.virtual_text(compiled.virt_texts, bufnr) +end + +return OrgAgendaLine diff --git a/lua/orgmode/agenda/view/token.lua b/lua/orgmode/agenda/view/token.lua new file mode 100644 index 000000000..1af10b6c2 --- /dev/null +++ b/lua/orgmode/agenda/view/token.lua @@ -0,0 +1,44 @@ +---@class OrgAgendaLineTokenOpts +---@field content string +---@field range? OrgRange +---@field virt_text_pos? string +---@field hl_group? string +---@field trim_for_hl? boolean + +---@class OrgAgendaLineToken: OrgAgendaLineTokenOpts +local OrgAgendaLineToken = {} +OrgAgendaLineToken.__index = OrgAgendaLineToken + +---@param opts OrgAgendaLineTokenOpts +---@return OrgAgendaLineToken +function OrgAgendaLineToken:new(opts) + local data = { + content = opts.content, + range = opts.range, + hl_group = opts.hl_group, + virt_text_pos = opts.virt_text_pos, + trim_for_hl = opts.trim_for_hl, + } + return setmetatable(data, OrgAgendaLineToken) +end + +function OrgAgendaLineToken:get_highlights() + if not self.hl_group or self.virt_text_pos then + return nil + end + local range = self.range + if self.trim_for_hl then + range = self.range:clone() + local start_offset = self.content:match('^%s*') + local end_offset = self.content:match('%s*$') + range.start_col = range.start_col + (start_offset and #start_offset or 0) + range.end_col = range.end_col - (end_offset and #end_offset or 0) + end + + return { + hlgroup = self.hl_group, + range = range, + } +end + +return OrgAgendaLineToken diff --git a/lua/orgmode/agenda/views/agenda.lua b/lua/orgmode/agenda/views/agenda.lua deleted file mode 100644 index d0df7bd5f..000000000 --- a/lua/orgmode/agenda/views/agenda.lua +++ /dev/null @@ -1,352 +0,0 @@ -local Date = require('orgmode.objects.date') -local Range = require('orgmode.files.elements.range') -local config = require('orgmode.config') -local ClockReport = require('orgmode.clock.report') -local AgendaItem = require('orgmode.agenda.agenda_item') -local AgendaFilter = require('orgmode.agenda.filter') -local utils = require('orgmode.utils') - -local function sort_by_date_or_priority_or_category(a, b) - if a.headline:get_priority_sort_value() ~= b.headline:get_priority_sort_value() then - return a.headline:get_priority_sort_value() > b.headline:get_priority_sort_value() - end - if not a.real_date:is_same(b.real_date, 'day') then - return a.real_date:is_before(b.real_date) - end - return a.index < b.index -end - ----@class OrgAgendaView ----@field span string|number ----@field from OrgDate ----@field to OrgDate ----@field items table[] ----@field content table[] ----@field highlights table[] ----@field clock_report OrgClockReport ----@field show_clock_report boolean ----@field start_on_weekday number ----@field start_day string ----@field header string ----@field filters OrgAgendaFilter ----@field files OrgFiles -local AgendaView = {} - -function AgendaView:new(opts) - opts = opts or {} - local data = { - content = {}, - highlights = {}, - items = {}, - span = opts.span or config:get_agenda_span(), - from = opts.from or Date.now():start_of('day'), - to = nil, - filters = opts.filters or AgendaFilter:new(), - clock_report = nil, - show_clock_report = opts.show_clock_report or false, - start_on_weekday = opts.org_agenda_start_on_weekday or config.org_agenda_start_on_weekday, - start_day = opts.org_agenda_start_day or config.org_agenda_start_day, - header = opts.org_agenda_overriding_header, - files = opts.files, - } - - setmetatable(data, self) - self.__index = self - data:_set_date_range() - return data -end - -function AgendaView:_get_title() - if self.header then - return self.header - end - local span = self.span - if type(span) == 'number' then - span = string.format('%d days', span) - end - local span_number = '' - if span == 'week' then - span_number = string.format(' (W%s)', self.from:get_week_number()) - end - return utils.capitalize(span) .. '-agenda' .. span_number .. ':' -end - -function AgendaView:_set_date_range(from) - local span = self.span - from = from or self.from - local is_week = span == 'week' or span == '7' - if is_week and self.start_on_weekday then - from = from:set_isoweekday(self.start_on_weekday) - end - - local to = nil - local modifier = { [span] = 1 } - if type(span) == 'number' then - modifier = { day = span } - end - - to = from:add(modifier) - - if self.start_day and type(self.start_day) == 'string' then - from = from:adjust(self.start_day) - to = to:adjust(self.start_day) - end - - self.span = span - self.from = from - self.to = to -end - -function AgendaView:_build_items() - local dates = self.from:get_range_until(self.to) - local agenda_days = {} - - local headline_dates = {} - for _, orgfile in ipairs(self.files:all()) do - for _, headline in ipairs(orgfile:get_opened_headlines()) do - for _, headline_date in ipairs(headline:get_valid_dates_for_agenda()) do - table.insert(headline_dates, { - headline_date = headline_date, - headline = headline, - }) - end - end - end - - for _, day in ipairs(dates) do - local date = { day = day, agenda_items = {} } - - for index, item in ipairs(headline_dates) do - local agenda_item = AgendaItem:new(item.headline_date, item.headline, day, index) - if agenda_item.is_valid and self.filters:matches(item.headline) then - table.insert(date.agenda_items, agenda_item) - end - end - - date.agenda_items = self._sort(date.agenda_items) - - table.insert(agenda_days, date) - end - - self.items = agenda_days -end - -function AgendaView:build() - self:_build_items() - local content = { { line_content = self:_get_title() } } - local highlights = {} - for _, item in ipairs(self.items) do - local day = item.day - local agenda_items = item.agenda_items - - local is_today = day:is_today() - local is_weekend = day:is_weekend() - - if is_today or is_weekend then - table.insert(highlights, { - hlgroup = is_today and '@org.agenda.today' or '@org.agenda.weekend', - range = Range:new({ - start_line = #content + 1, - end_line = #content + 1, - start_col = 1, - end_col = 0, - }), - }) - end - - table.insert(content, { line_content = self:_format_day(day) }) - - local longest_items = utils.reduce(agenda_items, function(acc, agenda_item) - acc.category = math.max(acc.category, vim.api.nvim_strwidth(agenda_item.headline:get_category())) - acc.label = math.max(acc.label, vim.api.nvim_strwidth(agenda_item.label)) - return acc - end, { - category = 0, - label = 0, - }) - local category_len = math.max(11, (longest_items.category + 1)) - local date_len = math.min(11, longest_items.label) - - for _, agenda_item in ipairs(agenda_items) do - table.insert(content, AgendaView.build_agenda_item_content(agenda_item, category_len, date_len, #content)) - end - end - - self.content = content - self.highlights = highlights - self.active_view = 'agenda' - if self.show_clock_report then - self.clock_report = ClockReport:new({ - from = self.from, - to = self.to, - files = self.files, - }) - utils.concat(self.content, self.clock_report:draw_for_agenda(#self.content + 1)) - end - return self -end - -function AgendaView:advance_span(direction, count) - count = count or 1 - direction = direction * count - local action = { [self.span] = direction } - if type(self.span) == 'number' then - action = { day = self.span * direction } - end - self.from = self.from:add(action) - self.to = self.to:add(action) - return self:build() -end - -function AgendaView:change_span(span) - if span == self.span then - return - end - if span == 'year' then - local c = vim.fn.confirm('Are you sure you want to print agenda for the whole year?', '&Yes\n&No') - if c ~= 1 then - return - end - end - self.span = span - self:_set_date_range() - return self:build() -end - -function AgendaView:goto_date(date) - self.to = nil - self:_set_date_range(date) - self:build() - vim.schedule(function() - vim.fn.search(self:_format_day(date)) - end) -end - -function AgendaView:reset() - return self:goto_date(Date.now():start_of('day')) -end - -function AgendaView:toggle_clock_report() - self.show_clock_report = not self.show_clock_report - local text = self.show_clock_report and 'on' or 'off' - utils.echo_info(string.format('Clocktable mode is %s', text)) - return self:build() -end - -function AgendaView:after_print(_) - return vim.fn.search(self:_format_day(Date.now())) -end - ----@param agenda_item OrgAgendaItem ----@return table -function AgendaView.build_agenda_item_content(agenda_item, longest_category, longest_date, line_nr) - local headline = agenda_item.headline - local category = ' ' .. utils.pad_right(string.format('%s:', headline:get_category()), longest_category) - local date = agenda_item.label - if date ~= '' then - date = ' ' .. utils.pad_right(agenda_item.label, longest_date) - end - local todo_keyword = agenda_item.headline:get_todo() or '' - local todo_padding = '' - if todo_keyword ~= '' and vim.trim(agenda_item.label):find(':$') then - todo_padding = ' ' - end - todo_keyword = todo_padding .. todo_keyword - local line = string.format('%s%s%s %s', category, date, todo_keyword, headline:get_title_with_priority()) - local todo_keyword_pos = string.format('%s%s%s', category, date, todo_padding):len() - if #headline:get_tags() > 0 then - local tags_string = headline:tags_to_string() - local padding_length = - math.max(1, utils.winwidth() - vim.api.nvim_strwidth(line) - vim.api.nvim_strwidth(tags_string)) - local indent = string.rep(' ', padding_length) - line = string.format('%s%s%s', line, indent, tags_string) - end - - local item_highlights = {} - if #agenda_item.highlights then - item_highlights = vim.tbl_map(function(hl) - hl.range = Range:new({ - start_line = line_nr + 1, - end_line = line_nr + 1, - start_col = 1, - end_col = 0, - }) - if hl.todo_keyword then - hl.range.start_col = todo_keyword_pos + 1 - hl.range.end_col = todo_keyword_pos + hl.todo_keyword:len() + 1 - end - if hl.priority then - hl.range.start_col = todo_keyword_pos + hl.start_col - hl.range.end_col = todo_keyword_pos + hl.start_col + 4 - end - return hl - end, agenda_item.highlights) - end - - if headline:is_clocked_in() then - table.insert(item_highlights, { - range = Range:new({ - start_line = line_nr + 1, - end_line = line_nr + 1, - start_col = 1, - end_col = 0, - }), - hlgroup = 'Visual', - whole_line = true, - }) - end - - return { - line_content = line, - line = line_nr, - jumpable = true, - file = headline.file.filename, - file_position = headline:get_range().start_line, - highlights = item_highlights, - longest_date = longest_date, - longest_category = longest_category, - agenda_item = agenda_item, - headline = headline, - } -end - -function AgendaView:_format_day(day) - return string.format('%-10s %s', day:format('%A'), day:format('%d %B %Y')) -end - ----@private ----@param agenda_items OrgAgendaItem[] ----@return OrgAgendaItem[] -function AgendaView._sort(agenda_items) - table.sort(agenda_items, function(a, b) - if a.is_same_day and b.is_same_day then - if a.real_date:has_time() and not b.real_date:has_time() then - return true - end - if b.real_date:has_time() and not a.real_date:has_time() then - return false - end - if a.real_date:has_time() and b.real_date:has_time() then - return a.real_date:is_before(b.real_date) - end - return sort_by_date_or_priority_or_category(a, b) - end - - if a.is_same_day and not b.is_same_day then - if a.real_date:has_time() or (b.real_date:is_none() and not a.real_date:is_none()) then - return true - end - end - - if not a.is_same_day and b.is_same_day then - if b.real_date:has_time() or (a.real_date:is_none() and not b.real_date:is_none()) then - return false - end - end - - return sort_by_date_or_priority_or_category(a, b) - end) - return agenda_items -end - -return AgendaView diff --git a/lua/orgmode/agenda/views/search.lua b/lua/orgmode/agenda/views/search.lua deleted file mode 100644 index 312d7b410..000000000 --- a/lua/orgmode/agenda/views/search.lua +++ /dev/null @@ -1,60 +0,0 @@ -local AgendaFilter = require('orgmode.agenda.filter') -local AgendaTodosView = require('orgmode.agenda.views.todos') -local Range = require('orgmode.files.elements.range') -local utils = require('orgmode.utils') - ----@class OrgAgendaSearchView ----@field items OrgHeadline[] ----@field content table[] ----@field highlights table[] ----@field header string ----@field search string ----@field filters OrgAgendaFilter ----@field files OrgFiles -local AgendaSearchView = {} - -function AgendaSearchView:new(opts) - opts = opts or {} - local data = { - content = {}, - highlights = {}, - items = {}, - search = opts.search or '', - filters = opts.filters or AgendaFilter:new(), - header = opts.org_agenda_overriding_header, - files = opts.files, - } - - setmetatable(data, self) - self.__index = self - return data -end - -function AgendaSearchView:build() - local search_term = self.search - if not self.filters.applying then - search_term = vim.fn.OrgmodeInput('Enter search term: ', self.search) - end - self.search = search_term - self.items = self.files:find_headlines_matching_search_term(search_term, false, true) - if self.filters:should_filter() then - self.items = vim.tbl_filter(function(item) - return self.filters:matches(item) - end, self.items) - end - - self.content = { - { line_content = 'Search words: ' .. search_term }, - { line_content = 'Press "r" to update search' }, - } - self.highlights = { - { hlgroup = 'Comment', range = Range.for_line_hl(1) }, - { hlgroup = 'Comment', range = Range.for_line_hl(2) }, - } - - self.active_view = 'search' - AgendaTodosView.generate_view(self.items, self.content, self.filters) - return self -end - -return AgendaSearchView diff --git a/lua/orgmode/agenda/views/tags.lua b/lua/orgmode/agenda/views/tags.lua deleted file mode 100644 index 35ba09d2e..000000000 --- a/lua/orgmode/agenda/views/tags.lua +++ /dev/null @@ -1,70 +0,0 @@ -local AgendaFilter = require('orgmode.agenda.filter') -local AgendaTodosView = require('orgmode.agenda.views.todos') -local Search = require('orgmode.files.elements.search') -local Range = require('orgmode.files.elements.range') -local utils = require('orgmode.utils') - ----@class OrgAgendaTagsView ----@field items table[] ----@field content table[] ----@field highlights table[] ----@field header string ----@field search string ----@field filters OrgAgendaFilter ----@field todo_only boolean ----@field files OrgFiles -local AgendaTagsView = {} - -function AgendaTagsView:new(opts) - opts = opts or {} - local data = { - content = {}, - highlights = {}, - items = {}, - search = opts.search or '', - todo_only = opts.todo_only or false, - filters = opts.filters or AgendaFilter:new(), - header = opts.org_agenda_overriding_header, - files = opts.files, - } - - setmetatable(data, self) - self.__index = self - return data -end - -function AgendaTagsView:build() - local tags = vim.fn.OrgmodeInput('Match: ', self.search, function(arg_lead) - return utils.prompt_autocomplete(arg_lead, self.files:get_tags()) - end) - if vim.trim(tags) == '' then - return utils.echo_warning('Invalid tag.') - end - local search = Search:new(tags) - self.items = {} - for _, orgfile in ipairs(self.files:all()) do - local headlines_filtered = orgfile:apply_search(search, self.todo_only) - for _, headline in ipairs(headlines_filtered) do - if self.filters:matches(headline) then - table.insert(self.items, headline) - end - end - end - - self.search = tags - self.content = { - { line_content = 'Headlines with TAGS match: ' .. tags }, - { line_content = 'Press "r" to update search' }, - } - self.highlights = { - { hlgroup = 'Comment', range = Range.for_line_hl(1) }, - { hlgroup = 'Comment', range = Range.for_line_hl(2) }, - } - - self.active_view = self.todo_only and 'tags_todo' or 'tags' - AgendaTodosView.generate_view(self.items, self.content, self.filters) - - return self -end - -return AgendaTagsView diff --git a/lua/orgmode/agenda/views/todos.lua b/lua/orgmode/agenda/views/todos.lua deleted file mode 100644 index c9e64f191..000000000 --- a/lua/orgmode/agenda/views/todos.lua +++ /dev/null @@ -1,148 +0,0 @@ -local AgendaFilter = require('orgmode.agenda.filter') -local Range = require('orgmode.files.elements.range') -local utils = require('orgmode.utils') -local agenda_highlights = require('orgmode.colors.highlights') -local hl_map = agenda_highlights.get_agenda_hl_map() - ----@class OrgAgendaTodosView ----@field items table[] ----@field content table[] ----@field highlights table[] ----@field header string ----@field search string ----@field filters OrgAgendaFilter ----@field files OrgFiles -local AgendaTodosView = {} - -function AgendaTodosView:new(opts) - opts = opts or {} - local data = { - content = {}, - highlights = {}, - items = {}, - search = opts.search or '', - filters = opts.filters or AgendaFilter:new(), - header = opts.org_agenda_overriding_header, - files = opts.files, - } - - setmetatable(data, self) - self.__index = self - return data -end - -function AgendaTodosView:build() - self.items = {} - for _, orgfile in ipairs(self.files:all()) do - for _, headline in ipairs(orgfile:get_unfinished_todo_entries()) do - if self.filters:matches(headline) then - table.insert(self.items, headline) - end - end - end - - self.content = { { line_content = 'Global list of TODO items of type: ALL' } } - self.highlights = {} - self.active_view = 'todos' - self.generate_view(self.items, self.content, self.filters) - return self -end - -function AgendaTodosView.generate_view(items, content, filters) - items = AgendaTodosView._sort(items) - local offset = #content - local longest_category = utils.reduce(items, function(acc, todo) - return math.max(acc, vim.api.nvim_strwidth(todo:get_category())) - end, 0) or 0 - - for i, headline in ipairs(items) do - if filters:matches(headline) then - table.insert(content, AgendaTodosView.generate_todo_item(headline, longest_category, i + offset)) - end - end - - return { items = items, content = content } -end - ----@param headline OrgHeadline ----@param longest_category number ----@param line_nr number -function AgendaTodosView.generate_todo_item(headline, longest_category, line_nr) - local category = ' ' .. utils.pad_right(string.format('%s:', headline:get_category()), longest_category + 1) - local todo_keyword, _, todo_type = headline:get_todo() - todo_keyword = todo_keyword or '' - local todo_keyword_padding = todo_keyword ~= '' and ' ' or '' - local title_with_priority = headline:get_title_with_priority() - local todo_keyword_len = todo_keyword:len() - local line = string.format(' %s%s%s %s', category, todo_keyword_padding, todo_keyword, title_with_priority) - if #headline:get_tags() > 0 then - local tags_string = headline:tags_to_string() - local padding_length = - math.max(1, utils.winwidth() - vim.api.nvim_strwidth(line) - vim.api.nvim_strwidth(tags_string)) - local indent = string.rep(' ', padding_length) - line = string.format('%s%s%s', line, indent, tags_string) - end - local todo_keyword_pos = category:len() + 4 - local highlights = {} - if todo_keyword ~= '' then - table.insert(highlights, { - hlgroup = hl_map[todo_keyword] or hl_map[todo_type], - range = Range:new({ - start_line = line_nr, - end_line = line_nr, - start_col = todo_keyword_pos, - end_col = todo_keyword_pos + todo_keyword_len, - }), - }) - end - local priority = headline:get_priority() - if priority and hl_map.priority[priority] then - local col_start = todo_keyword_pos + (todo_keyword_len > 0 and todo_keyword_len + 1 or 0) - table.insert(highlights, { - hlgroup = hl_map.priority[priority].hl_group, - range = Range:new({ - start_line = line_nr, - end_line = line_nr, - start_col = col_start, - end_col = col_start + 4, - }), - }) - end - if headline:is_clocked_in() then - table.insert(highlights, { - range = Range:new({ - start_line = line_nr, - end_line = line_nr, - start_col = 1, - end_col = 0, - }), - hlgroup = 'Visual', - whole_line = true, - }) - end - return { - line_content = line, - longest_category = longest_category, - line = line_nr, - jumpable = true, - file = headline.file.filename, - file_position = headline:get_range().start_line, - headline = headline, - highlights = highlights, - } -end - ----@private ----@param todos OrgHeadline[] ----@return OrgHeadline[] -function AgendaTodosView._sort(todos) - table.sort(todos, function(a, b) - if a:get_priority_sort_value() ~= b:get_priority_sort_value() then - return a:get_priority_sort_value() > b:get_priority_sort_value() - end - return a:get_category() < b:get_category() - end) - return todos -end - -return AgendaTodosView diff --git a/lua/orgmode/api/agenda.lua b/lua/orgmode/api/agenda.lua index 3acf8de47..b78afc8d9 100644 --- a/lua/orgmode/api/agenda.lua +++ b/lua/orgmode/api/agenda.lua @@ -20,22 +20,30 @@ local function get_date(date, name) error(('Invalid format for "%s" date in Org Agenda'):format(name)) end +local function get_opts(options) + options = options or {} + local opts = {} + if options.filters and options.filters ~= '' then + opts.filter = options.filters + end + opts.header = options.header + return opts +end + ---@class OrgApiAgendaOptions ---@field filters? OrgApiAgendaFilter ---@field from? string | OrgDate ----@field span? number | 'day' | 'week' | 'month' | 'year' +---@field span? OrgAgendaSpan +---@field header? string ---@param options? OrgApiAgendaOptions function OrgAgenda.agenda(options) options = options or {} - if options.filters and options.filters ~= '' then - orgmode.agenda.filters:parse(options.filters, true) - end - local from = get_date(options.from, 'from') - orgmode.agenda:agenda({ - from = from, - span = options.span, - }) + local opts = get_opts(options) + opts.from = get_date(options.from, 'from') + opts.span = options.span + opts.header = options.header + orgmode.agenda:agenda(opts) end ---@class OrgAgendaTodosOptions @@ -44,10 +52,8 @@ end ---@param options? OrgAgendaTodosOptions function OrgAgenda.todos(options) options = options or {} - if options.filters and options.filters ~= '' then - orgmode.agenda.filters:parse(options.filters, true) - end - orgmode.agenda:todos() + local opts = get_opts(options) + orgmode.agenda:todos(opts) end ---@class OrgAgendaTagsOptions @@ -57,12 +63,19 @@ end ---@param options? OrgAgendaTagsOptions function OrgAgenda.tags(options) options = options or {} - if options.filters and options.filters ~= '' then - orgmode.agenda.filters:parse(options.filters, true) - end - orgmode.agenda:tags({ - todo_only = options.todo_only, - }) + local opts = get_opts(options) + orgmode.agenda:tags(opts) +end + +---@class OrgAgendaTagsTodoOptions +---@field filters? OrgApiAgendaFilter +---@field todo_only? boolean + +---@param options? OrgAgendaTagsOptions +function OrgAgenda.tags_todo(options) + options = options or {} + local opts = get_opts(options) + orgmode.agenda:tags(opts) end return OrgAgenda diff --git a/lua/orgmode/clock/report.lua b/lua/orgmode/clock/report.lua index d22cadc0f..a1a4ac046 100644 --- a/lua/orgmode/clock/report.lua +++ b/lua/orgmode/clock/report.lua @@ -22,8 +22,8 @@ function ClockReport:new(opts) end ---@param start_line number ----@return table[] -function ClockReport:draw_for_agenda(start_line) +---@return OrgTable +function ClockReport:get_table_report(start_line) local report = self:generate_report() local data = { { 'File', 'Headline', 'Time' }, @@ -33,7 +33,7 @@ function ClockReport:draw_for_agenda(start_line) } for _, file in ipairs(report.files_with_clocks) do - table.insert(data, { { value = file.name, reference = file }, 'File time', file.total_duration:to_string() }) + table.insert(data, { { value = file.name }, 'File time', file.total_duration:to_string() }) for _, headline in ipairs(file.headlines) do table.insert(data, { '', @@ -44,44 +44,7 @@ function ClockReport:draw_for_agenda(start_line) table.insert(data, 'hr') end - local clock_table = Table.from_list(data, start_line, 0):compile() - self.table = clock_table - local result = {} - for i, row in ipairs(clock_table.rows) do - local highlights = {} - local prev_row = clock_table.rows[i - 1] - if prev_row and prev_row.is_separator then - for _, cell in ipairs(row.cells) do - local range = cell.range:clone() - range.start_col = range.start_col + 1 - range.end_col = range.end_col + 2 - table.insert(highlights, { - hlgroup = '@org.bold', - range = range, - }) - end - elseif i > 1 and not row.is_separator then - for _, cell in ipairs(row.cells) do - if cell.reference then - local range = cell.range:clone() - range.start_col = range.start_col + 1 - range.end_col = range.end_col + 2 - table.insert(highlights, { - hlgroup = '@org.hyperlink', - range = range, - }) - end - end - end - - table.insert(result, { - line_content = row.content, - is_table = true, - table_row = row, - highlights = highlights, - }) - end - return result + return Table.from_list(data, start_line, 0):compile() end function ClockReport:generate_report() @@ -125,28 +88,4 @@ function ClockReport:_get_clock_report_for_file(orgfile) } end ----@param item table -function ClockReport:find_agenda_item(item) - local line = vim.fn.line('.') - local col = vim.fn.col('.') - local found_cell = nil - for _, cell in ipairs(item.table_row.cells) do - if cell.range:is_in_range(line, col) then - found_cell = cell - break - end - end - - if found_cell and found_cell.reference then - return { - jumpable = true, - file = found_cell.reference.file.filename, - file_position = found_cell.reference:get_range().start_line, - } - end - return { - jumpable = false, - } -end - return ClockReport diff --git a/lua/orgmode/colors/init.lua b/lua/orgmode/colors/init.lua index 5506e1af2..7a04b8940 100644 --- a/lua/orgmode/colors/init.lua +++ b/lua/orgmode/colors/init.lua @@ -98,4 +98,43 @@ M.highlight = function(highlights, clear, bufnr) end end +---@param virt_texts{ range: OrgRange, content: string, virt_text_pos: string, hl_groups: string[] }[] +---@param bufnr number +M.virtual_text = function(virt_texts, bufnr) + bufnr = bufnr or 0 + for _, virt_text in ipairs(virt_texts) do + local start_line = virt_text.range.start_line - 1 + vim.api.nvim_buf_set_extmark(bufnr, namespace, start_line, 0, { + virt_text = { { virt_text.content, virt_text.hl_groups } }, + virt_text_pos = virt_text.virt_text_pos, + hl_mode = 'combine', + }) + end +end + +---@param bufnr number +---@param start_line? number Default: 0 +---@param end_line? number Default: -1 +M.clear_extmarks = function(bufnr, start_line, end_line) + local extmarks = vim.api.nvim_buf_get_extmarks( + bufnr, + namespace, + { start_line, 0 }, + { end_line, 9999 }, + { details = true } + ) + for _, extmark in ipairs(extmarks) do + vim.api.nvim_buf_del_extmark(bufnr, namespace, extmark[1]) + end +end + +M.add_hr = function(bufnr, line) + vim.api.nvim_buf_set_lines(bufnr, line, line, false, { '' }) + local width = vim.api.nvim_win_get_width(0) + vim.api.nvim_buf_set_extmark(bufnr, namespace, line, 0, { + virt_text = { { string.rep('-', width), '@org.agenda.separator' } }, + virt_text_pos = 'overlay', + }) +end + return M diff --git a/lua/orgmode/config/defaults.lua b/lua/orgmode/config/defaults.lua index f2c18d9a4..7414443dd 100644 --- a/lua/orgmode/config/defaults.lua +++ b/lua/orgmode/config/defaults.lua @@ -1,6 +1,8 @@ +---@alias OrgAgendaSpan 'day' | 'week' | 'month' | 'year' | number +--- ---@class OrgDefaultConfig ---@field org_id_method 'uuid' | 'ts' | 'org' ----@field org_agenda_span 'day' | 'week' | 'month' | 'year' | number +---@field org_agenda_span OrgAgendaSpan ---@field org_log_repeat 'time' | 'note' | false ---@field calendar { round_min_with_hours: boolean, min_big_step: number, min_small_step: number? } local DefaultConfig = { diff --git a/lua/orgmode/config/mappings/init.lua b/lua/orgmode/config/mappings/init.lua index b0e62dedb..1af3fffed 100644 --- a/lua/orgmode/config/mappings/init.lua +++ b/lua/orgmode/config/mappings/init.lua @@ -49,7 +49,7 @@ return { ), org_agenda_redo = m.action( 'agenda.redo', - { opts = { desc = 'org redo', help_desc = 'Reload org files and redraw' } } + { args = { 'mapping' }, opts = { desc = 'org redo', help_desc = 'Reload org files and redraw' } } ), org_agenda_todo = m.action( 'agenda.change_todo_state', diff --git a/lua/orgmode/files/file.lua b/lua/orgmode/files/file.lua index 5224bde3d..1102cd8dd 100644 --- a/lua/orgmode/files/file.lua +++ b/lua/orgmode/files/file.lua @@ -276,6 +276,7 @@ function OrgFile:apply_search(search, todo_only) local scheduled = item:get_scheduled_date() local closed = item:get_closed_date() local properties = item:get_properties() + local priority = item:get_priority() return search:check({ props = vim.tbl_extend('keep', {}, properties, { @@ -283,6 +284,7 @@ function OrgFile:apply_search(search, todo_only) deadline = deadline and deadline:to_wrapped_string(true), scheduled = scheduled and scheduled:to_wrapped_string(true), closed = closed and closed:to_wrapped_string(false), + priority = priority, }), tags = item:get_tags(), todo = item:get_todo() or '', diff --git a/lua/orgmode/files/headline.lua b/lua/orgmode/files/headline.lua index c008dd2aa..1f5801504 100644 --- a/lua/orgmode/files/headline.lua +++ b/lua/orgmode/files/headline.lua @@ -207,7 +207,7 @@ end ---@return boolean function Headline:has_tag(tag) for _, tag_item in ipairs(self:get_tags()) do - if tag_item:lower() == tag:lower() then + if tag_item == tag then return true end end @@ -757,7 +757,7 @@ end ---@param category string ---@return boolean function Headline:matches_category(category) - return self:get_category():lower() == category:lower() + return self:get_category() == category end ---@return OrgDate[] diff --git a/lua/orgmode/ui/menu.lua b/lua/orgmode/ui/menu.lua index 602b19aaf..d313ffca1 100644 --- a/lua/orgmode/ui/menu.lua +++ b/lua/orgmode/ui/menu.lua @@ -12,15 +12,17 @@ local config = require('orgmode.config') ---@alias OrgMenuItem OrgMenuOption | OrgMenuSeparator ---- Menu for selecting an action by pressing a key by the user ----@class OrgMenu +---@class OrgMenuOpts ---@field title string Menu title ---@field items OrgMenuItem[]? Menu items, may include options and separators ---@field prompt string Prompt text used to prompt a keystroke ---@field separator OrgMenuSeparator? Default separator + +--- Menu for selecting an action by pressing a key by the user +---@class OrgMenu:OrgMenuOpts local Menu = {} ----@param data OrgMenu +---@param data OrgMenuOpts function Menu:new(data) self:_validate_data(data) @@ -77,7 +79,7 @@ function Menu:_validate_separator(separator) end end ----@param data OrgMenu +---@param data OrgMenuOpts function Menu:_validate_data(data) validator.validate({ title = { data.title, 'string' }, diff --git a/syntax/orgagenda.vim b/syntax/orgagenda.vim index 17115aba4..c3cbedc83 100644 --- a/syntax/orgagenda.vim +++ b/syntax/orgagenda.vim @@ -8,6 +8,8 @@ syn match org_hyperlinkBracketsRight contained "\]\{2}" conceal hi default link @org.agenda.day Statement hi default link @org.agenda.today @org.bold hi default link @org.agenda.weekend @org.bold +hi default link @org.agenda.header Comment +hi default link @org.agenda.separator Comment hi default @org.agenda.tag gui=bold cterm=bold hi default link org_hyperlink @org.hyperlink diff --git a/tests/plenary/agenda/agenda_item_spec.lua b/tests/plenary/agenda/agenda_item_spec.lua index a143a25ec..9d0596157 100644 --- a/tests/plenary/agenda/agenda_item_spec.lua +++ b/tests/plenary/agenda/agenda_item_spec.lua @@ -22,13 +22,11 @@ describe('Agenda item', function() local headline = generate(string.format('Inactive date [%s]', today:to_string())) local agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.False(agenda_item.is_valid) - assert.are.same({}, agenda_item.highlights) assert.are.same('', agenda_item.label) headline = generate(string.format('CLOSED: [%s]', today:to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.False(agenda_item.is_valid) - assert.are.same({}, agenda_item.highlights) assert.are.same('', agenda_item.label) end) @@ -37,24 +35,19 @@ describe('Agenda item', function() local headline = generate(string.format('Some content <%s>', today:to_string())) local agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.True(agenda_item.is_valid) - assert.are.same({ - { - hlgroup = hl_map.TODO, - todo_keyword = 'TODO', - }, - }, agenda_item.highlights) + assert.is.same(hl_map.TODO, agenda_item:get_todo_hlgroup()) + assert.is.Nil(agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(today:format_time() .. '...... ', agenda_item.label) headline = generate(string.format('Some content <%s>', today:subtract({ day = 2 }):to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.False(agenda_item.is_valid) - assert.are.same({}, agenda_item.highlights) assert.are.same('', agenda_item.label) headline = generate(string.format('Some content <%s>', today:add({ day = 2 }):to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.False(agenda_item.is_valid) - assert.are.same({}, agenda_item.highlights) assert.are.same('', agenda_item.label) end) @@ -64,97 +57,70 @@ describe('Agenda item', function() local headline = generate(string.format('DEADLINE: <%s>', today:to_string())) local agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.True(agenda_item.is_valid) - assert.are.same({ - { hlgroup = hl_map.deadline }, - { - hlgroup = hl_map.TODO, - todo_keyword = 'TODO', - }, - }, agenda_item.highlights) + assert.is.same(hl_map.TODO, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.deadline, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(today:format_time() .. '...... Deadline:', agenda_item.label) -- past headline = generate(string.format('DEADLINE: <%s>', today:subtract({ day = 7 }):to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.True(agenda_item.is_valid) - assert.are.same({ - { hlgroup = hl_map.deadline }, - { - hlgroup = hl_map.TODO, - todo_keyword = 'TODO', - }, - }, agenda_item.highlights) + assert.is.same(hl_map.TODO, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.deadline, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.truthy(agenda_item.label:find('[78]%sd%.%sago:'), 'Relative days ago is invalid.') -- ignores past that are done headline = generate(string.format('DEADLINE: <%s>', today:subtract({ day = 7 }):to_string()), 'DONE') agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.False(agenda_item.is_valid) - assert.are.same({}, agenda_item.highlights) assert.are.same('', agenda_item.label) -- ignores future that are done headline = generate(string.format('DEADLINE: <%s>', today:add({ day = 7 }):to_string()), 'DONE') agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.False(agenda_item.is_valid) - assert.are.same({}, agenda_item.highlights) assert.are.same('', agenda_item.label) -- future without warning within the default warning days headline = generate(string.format('DEADLINE: <%s>', today:add({ day = 9 }):to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.True(agenda_item.is_valid) - assert.are.same({ - { - hlgroup = hl_map.TODO, - todo_keyword = 'TODO', - }, - }, agenda_item.highlights) + assert.is.same(hl_map.TODO, agenda_item:get_todo_hlgroup()) + assert.is.Nil(agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same('In 9 d.:', agenda_item.label) -- future without warning within the default warning days and less than 6 days (highlights as warning) headline = generate(string.format('DEADLINE: <%s>', today:add({ day = 6 }):to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.True(agenda_item.is_valid) - assert.are.same({ - { hlgroup = hl_map.warning }, - { - hlgroup = hl_map.TODO, - todo_keyword = 'TODO', - }, - }, agenda_item.highlights) + assert.is.same(hl_map.TODO, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.warning, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same('In 6 d.:', agenda_item.label) -- future with warning within the defined warning period headline = generate(string.format('DEADLINE: <%s -10d>', today:add({ day = 9 }):to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.True(agenda_item.is_valid) - assert.are.same({ - { - hlgroup = hl_map.TODO, - todo_keyword = 'TODO', - }, - }, agenda_item.highlights) + assert.is.same(hl_map.TODO, agenda_item:get_todo_hlgroup()) assert.are.same('In 9 d.:', agenda_item.label) -- future with warning within the defined warning period and less than 6 days (highlights as warning) headline = generate(string.format('DEADLINE: <%s -10d>', today:add({ day = 6 }):to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.True(agenda_item.is_valid) - assert.are.same({ - { hlgroup = hl_map.warning }, - { - hlgroup = hl_map.TODO, - todo_keyword = 'TODO', - }, - }, agenda_item.highlights) + assert.is.same(hl_map.TODO, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.warning, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same('In 6 d.:', agenda_item.label) -- future with warning outside of defined warning period is not shown headline = generate(string.format('DEADLINE: <%s -7d>', today:add({ day = 8 }):to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.False(agenda_item.is_valid) - assert.are.same({}, agenda_item.highlights) assert.are.same('', agenda_item.label) end) @@ -164,73 +130,54 @@ describe('Agenda item', function() local headline = generate(string.format('SCHEDULED: <%s>', today:to_string())) local agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.True(agenda_item.is_valid) - assert.are.same({ - { hlgroup = hl_map.ok }, - { - hlgroup = hl_map.TODO, - todo_keyword = 'TODO', - }, - }, agenda_item.highlights) + assert.is.same(hl_map.TODO, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.ok, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(today:format_time() .. '...... Scheduled:', agenda_item.label) -- Past undone headline = generate(string.format('SCHEDULED: <%s>', today:subtract({ day = 7 }):to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.True(agenda_item.is_valid) - assert.are.same({ - { hlgroup = hl_map.warning }, - { - hlgroup = hl_map.TODO, - todo_keyword = 'TODO', - }, - }, agenda_item.highlights) + assert.is.same(hl_map.TODO, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.warning, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same('Sched. 7x:', agenda_item.label) -- Past done ignored headline = generate(string.format('SCHEDULED: <%s>', today:subtract({ day = 7 }):to_string()), 'DONE') agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.False(agenda_item.is_valid) - assert.are.same({}, agenda_item.highlights) assert.are.same('', agenda_item.label) -- Future ignored headline = generate(string.format('SCHEDULED: <%s>', today:add({ day = 7 }):to_string()), 'DONE') agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.False(agenda_item.is_valid) - assert.are.same({}, agenda_item.highlights) assert.are.same('', agenda_item.label) -- Undone adjusted for today shown headline = generate(string.format('SCHEDULED: <%s -2d>', today:subtract({ day = 2 }):to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.True(agenda_item.is_valid) - assert.are.same({ - { hlgroup = hl_map.warning }, - { - hlgroup = hl_map.TODO, - todo_keyword = 'TODO', - }, - }, agenda_item.highlights) + assert.is.same(hl_map.TODO, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.warning, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same('Sched. 2x:', agenda_item.label) -- Undone adjusted for today or past shown headline = generate(string.format('SCHEDULED: <%s -2d>', today:subtract({ day = 4 }):to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.True(agenda_item.is_valid) - assert.are.same({ - { hlgroup = hl_map.warning }, - { - hlgroup = hl_map.TODO, - todo_keyword = 'TODO', - }, - }, agenda_item.highlights) + assert.is.same(hl_map.TODO, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.warning, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same('Sched. 4x:', agenda_item.label) -- Done adjusted for today ignored headline = generate(string.format('SCHEDULED: <%s -2d>', today:subtract({ day = 2 }):to_string()), 'DONE') agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.False(agenda_item.is_valid) - assert.are.same({}, agenda_item.highlights) assert.are.same('', agenda_item.label) end) @@ -240,20 +187,18 @@ describe('Agenda item', function() local headline = generate(string.format('SCHEDULED: <%s>', today:to_string()), 'DONE') local agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.True(agenda_item.is_valid) - assert.are.same( - { { hlgroup = hl_map.ok }, { hlgroup = hl_map.DONE, todo_keyword = 'DONE' } }, - agenda_item.highlights - ) + assert.is.same(hl_map.DONE, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.ok, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(today:format_time() .. '...... Scheduled:', agenda_item.label) -- Deadline done shown by default local headline_deadline = generate(string.format('DEADLINE: <%s>', today:to_string()), 'DONE') local agenda_item_deadline = AgendaItem:new(headline_deadline:get_all_dates()[1], headline_deadline, today) assert.is.True(agenda_item_deadline.is_valid) - assert.are.same( - { { hlgroup = hl_map.ok }, { hlgroup = hl_map.DONE, todo_keyword = 'DONE' } }, - agenda_item_deadline.highlights - ) + assert.is.same(hl_map.DONE, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.ok, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(today:format_time() .. '...... Deadline:', agenda_item_deadline.label) config:extend({ org_agenda_skip_scheduled_if_done = true }) @@ -261,15 +206,13 @@ describe('Agenda item', function() -- Scheduled done hidden agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.False(agenda_item.is_valid) - assert.are.same({}, agenda_item.highlights) assert.are.same('', agenda_item.label) -- Deadline done still showing agenda_item_deadline = AgendaItem:new(headline_deadline:get_all_dates()[1], headline_deadline, today) assert.is.True(agenda_item_deadline.is_valid) - assert.are.same( - { { hlgroup = hl_map.ok }, { hlgroup = hl_map.DONE, todo_keyword = 'DONE' } }, - agenda_item_deadline.highlights - ) + assert.is.same(hl_map.DONE, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.ok, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(today:format_time() .. '...... Deadline:', agenda_item_deadline.label) config:extend({ org_agenda_skip_scheduled_if_done = false }) @@ -281,36 +224,32 @@ describe('Agenda item', function() local headline = generate(string.format('SCHEDULED: <%s>', today:to_string()), 'DONE') local agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) assert.is.True(agenda_item.is_valid) - assert.are.same( - { { hlgroup = hl_map.ok }, { hlgroup = hl_map.DONE, todo_keyword = 'DONE' } }, - agenda_item.highlights - ) + assert.is.same(hl_map.DONE, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.ok, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(today:format_time() .. '...... Scheduled:', agenda_item.label) -- Deadline done shown by default local headline_deadline = generate(string.format('DEADLINE: <%s>', today:to_string()), 'DONE') local agenda_item_deadline = AgendaItem:new(headline_deadline:get_all_dates()[1], headline_deadline, today) assert.is.True(agenda_item_deadline.is_valid) - assert.are.same( - { { hlgroup = hl_map.ok }, { hlgroup = hl_map.DONE, todo_keyword = 'DONE' } }, - agenda_item_deadline.highlights - ) + assert.is.same(hl_map.DONE, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.ok, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(today:format_time() .. '...... Deadline:', agenda_item_deadline.label) config:extend({ org_agenda_skip_deadline_if_done = true }) -- Scheduled done still showing assert.is.True(agenda_item.is_valid) - assert.are.same( - { { hlgroup = hl_map.ok }, { hlgroup = hl_map.DONE, todo_keyword = 'DONE' } }, - agenda_item.highlights - ) + assert.is.same(hl_map.DONE, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.ok, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(today:format_time() .. '...... Scheduled:', agenda_item.label) -- Deadline done hidden agenda_item_deadline = AgendaItem:new(headline_deadline:get_all_dates()[1], headline_deadline, today) assert.is.False(agenda_item_deadline.is_valid) - assert.are.same({}, agenda_item_deadline.highlights) assert.are.same('', agenda_item_deadline.label) config:extend({ org_agenda_skip_deadline_if_done = false }) @@ -324,13 +263,11 @@ describe('Agenda item', function() local end_date = headline:get_all_dates()[2] local agenda_item_start_date = AgendaItem:new(start_date, headline, today) assert.is.True(agenda_item_start_date.is_valid) - assert.are.same( - { { hlgroup = hl_map.deadline }, { hlgroup = hl_map.TODO, todo_keyword = 'TODO' } }, - agenda_item_start_date.highlights - ) + assert.is.same(hl_map.TODO, agenda_item_start_date:get_todo_hlgroup()) + assert.is.same(hl_map.deadline, agenda_item_start_date:get_hlgroup()) + assert.is.Nil(agenda_item_start_date:get_priority_hlgroup()) local agenda_item_end_date = AgendaItem:new(end_date, headline, today) assert.is.False(agenda_item_end_date.is_valid) - assert.are.same({}, agenda_item_end_date.highlights) end) it('should ignore scheduled dates that are end dates for a range', function() @@ -341,13 +278,11 @@ describe('Agenda item', function() local end_date = headline:get_all_dates()[2] local agenda_item_start_date = AgendaItem:new(start_date, headline, today) assert.is.True(agenda_item_start_date.is_valid) - assert.are.same( - { { hlgroup = hl_map.ok }, { hlgroup = hl_map.TODO, todo_keyword = 'TODO' } }, - agenda_item_start_date.highlights - ) + assert.is.same(hl_map.TODO, agenda_item_start_date:get_todo_hlgroup()) + assert.is.same(hl_map.ok, agenda_item_start_date:get_hlgroup()) + assert.is.Nil(agenda_item_start_date:get_priority_hlgroup()) local agenda_item_end_date = AgendaItem:new(end_date, headline, today) assert.is.False(agenda_item_end_date.is_valid) - assert.are.same({}, agenda_item_end_date.highlights) end) end) @@ -357,13 +292,11 @@ describe('Agenda item', function() local headline = generate(string.format('Inactive date [%s]', future_day:to_string())) local agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, future_day) assert.is.False(agenda_item.is_valid) - assert.are.same({}, agenda_item.highlights) assert.are.same('', agenda_item.label) headline = generate(string.format('CLOSED: [%s]', future_day:to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, future_day) assert.is.False(agenda_item.is_valid) - assert.are.same({}, agenda_item.highlights) assert.are.same('', agenda_item.label) end) @@ -373,19 +306,15 @@ describe('Agenda item', function() local headline = generate(string.format('Some content <%s>', future_day:to_string())) local agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, future_day) assert.is.True(agenda_item.is_valid) - assert.are.same({ - { - hlgroup = hl_map.TODO, - todo_keyword = 'TODO', - }, - }, agenda_item.highlights) + assert.is.same(hl_map.TODO, agenda_item:get_todo_hlgroup()) + assert.is.Nil(agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(future_day:format_time() .. '...... ', agenda_item.label) -- Invalid for any other day headline = generate(string.format('Some content <%s>', future_day:add({ day = 1 }):to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, future_day) assert.is.False(agenda_item.is_valid) - assert.are.same({}, agenda_item.highlights) assert.are.same('', agenda_item.label) end) @@ -395,33 +324,24 @@ describe('Agenda item', function() local headline = generate(string.format('DEADLINE: <%s>', future_day:to_string())) local agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, future_day) assert.is.True(agenda_item.is_valid) - assert.are.same({ - { hlgroup = hl_map.deadline }, - { - hlgroup = hl_map.TODO, - todo_keyword = 'TODO', - }, - }, agenda_item.highlights) + assert.is.same(hl_map.TODO, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.deadline, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(future_day:format_time() .. '...... Deadline:', agenda_item.label) -- Green highlight if it's done headline = generate(string.format('DEADLINE: <%s>', future_day:to_string()), 'DONE') agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, future_day) assert.is.True(agenda_item.is_valid) - assert.are.same({ - { hlgroup = hl_map.ok }, - { - hlgroup = hl_map.DONE, - todo_keyword = 'DONE', - }, - }, agenda_item.highlights) + assert.is.same(hl_map.DONE, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.ok, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(future_day:format_time() .. '...... Deadline:', agenda_item.label) -- Invalid for any other day headline = generate(string.format('DEADLINE: <%s>', future_day:add({ day = 1 }):to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, future_day) assert.is.False(agenda_item.is_valid) - assert.are.same({}, agenda_item.highlights) assert.are.same('', agenda_item.label) end) @@ -431,15 +351,9 @@ describe('Agenda item', function() local headline = generate(string.format('SCHEDULED: <%s>', future_day:to_string())) local agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, future_day) assert.is.True(agenda_item.is_valid) - assert.are.same({ - { - hlgroup = hl_map.ok, - }, - { - hlgroup = hl_map.TODO, - todo_keyword = 'TODO', - }, - }, agenda_item.highlights) + assert.is.same(hl_map.TODO, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.ok, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(future_day:format_time() .. '...... Scheduled:', agenda_item.label) -- Valid for same day, shows as yellow if it's past @@ -447,29 +361,21 @@ describe('Agenda item', function() headline = generate(string.format('SCHEDULED: <%s>', past_day:to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, past_day) assert.is.True(agenda_item.is_valid) - assert.are.same({ - { - hlgroup = hl_map.warning, - }, - { - hlgroup = hl_map.TODO, - todo_keyword = 'TODO', - }, - }, agenda_item.highlights) + assert.is.same(hl_map.TODO, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.warning, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(past_day:format_time() .. '...... Scheduled:', agenda_item.label) -- Invalid for any other day headline = generate(string.format('SCHEDULED: <%s>', future_day:add({ day = 1 }):to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, future_day) assert.is.False(agenda_item.is_valid) - assert.are.same({}, agenda_item.highlights) assert.are.same('', agenda_item.label) -- Invalid if it has an adjustment headline = generate(string.format('SCHEDULED: <%s -2d>', future_day:add({ day = 1 }):to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, future_day) assert.is.False(agenda_item.is_valid) - assert.are.same({}, agenda_item.highlights) assert.are.same('', agenda_item.label) end) end) @@ -490,7 +396,7 @@ describe('Agenda item', function() it('should properly read same day date ranges and time ranges', function() -- Same day date range - local range_start = Date.from_string('2021-06-13 Sun 13:30') + local range_start = Date.from_string('2021-06-13 Sun 13:30') --[[@as OrgDate]] local range_end = range_start:add({ hour = 1 }) local headline = generate(string.format('Some text <%s>--<%s>', range_start:to_string(), range_end:to_string())) local day = range_start:clone() @@ -501,15 +407,15 @@ describe('Agenda item', function() assert.is.False(agenda_item.is_valid) -- Time range on a single date - local date_with_time_range = Date.from_string('2021-06-13 Sun 15:00-16:30') + local date_with_time_range = Date.from_string('2021-06-13 Sun 15:00-16:30') --[[@as OrgDate]] headline = generate(string.format('Some text <%s>', date_with_time_range:to_string())) agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, date_with_time_range) assert.is.True(agenda_item.is_valid) assert.are.same(agenda_item.label, '15:00-16:30 ') -- Time range on a date has precedence over same day date range - date_with_time_range = Date.from_string('2021-06-13 Sun 18:00-19:30') - local date_with_time_range_end = Date.from_string('2021-06-13 Sun 20:00') + date_with_time_range = Date.from_string('2021-06-13 Sun 18:00-19:30') --[[@as OrgDate]] + local date_with_time_range_end = Date.from_string('2021-06-13 Sun 20:00') --[[@as OrgDate]] headline = generate( string.format('Some text <%s>--<%s>', date_with_time_range:to_string(), date_with_time_range_end:to_string()) ) @@ -518,30 +424,24 @@ describe('Agenda item', function() assert.are.same(agenda_item.label, '18:00-19:30 ') end) - local range_start = Date.from_string('2021-06-13 Sun 13:30') - local range_end = range_start:add({ day = 4, hour = 1 }) - local headline = generate(string.format('Some text <%s>--<%s>', range_start:to_string(), range_end:to_string())) - it('should not show scheduled DONE item if disabled in config', function() local future_day = Date.now():add({ day = 2 }) -- Scheduled done shown by default local headline = generate(string.format('SCHEDULED: <%s>', future_day:to_string()), 'DONE') local agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, future_day) assert.is.True(agenda_item.is_valid) - assert.are.same( - { { hlgroup = hl_map.ok }, { hlgroup = hl_map.DONE, todo_keyword = 'DONE' } }, - agenda_item.highlights - ) + assert.is.same(hl_map.DONE, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.ok, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(future_day:format_time() .. '...... Scheduled:', agenda_item.label) -- Deadline done shown by default local headline_deadline = generate(string.format('DEADLINE: <%s>', future_day:to_string()), 'DONE') local agenda_item_deadline = AgendaItem:new(headline_deadline:get_all_dates()[1], headline_deadline, future_day) assert.is.True(agenda_item_deadline.is_valid) - assert.are.same( - { { hlgroup = hl_map.ok }, { hlgroup = hl_map.DONE, todo_keyword = 'DONE' } }, - agenda_item_deadline.highlights - ) + assert.is.same(hl_map.DONE, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.ok, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(future_day:format_time() .. '...... Deadline:', agenda_item_deadline.label) config:extend({ org_agenda_skip_scheduled_if_done = true }) @@ -549,15 +449,13 @@ describe('Agenda item', function() -- Scheduled done hidden agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, future_day) assert.is.False(agenda_item.is_valid) - assert.are.same({}, agenda_item.highlights) assert.are.same('', agenda_item.label) -- Deadline done still showing agenda_item_deadline = AgendaItem:new(headline_deadline:get_all_dates()[1], headline_deadline, future_day) assert.is.True(agenda_item_deadline.is_valid) - assert.are.same( - { { hlgroup = hl_map.ok }, { hlgroup = hl_map.DONE, todo_keyword = 'DONE' } }, - agenda_item_deadline.highlights - ) + assert.is.same(hl_map.DONE, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.ok, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(future_day:format_time() .. '...... Deadline:', agenda_item_deadline.label) config:extend({ org_agenda_skip_scheduled_if_done = false }) @@ -569,36 +467,32 @@ describe('Agenda item', function() local headline = generate(string.format('SCHEDULED: <%s>', past_day:to_string()), 'DONE') local agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, past_day) assert.is.True(agenda_item.is_valid) - assert.are.same( - { { hlgroup = hl_map.ok }, { hlgroup = hl_map.DONE, todo_keyword = 'DONE' } }, - agenda_item.highlights - ) + assert.is.same(hl_map.DONE, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.ok, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(past_day:format_time() .. '...... Scheduled:', agenda_item.label) -- Deadline done shown by default local headline_deadline = generate(string.format('DEADLINE: <%s>', past_day:to_string()), 'DONE') local agenda_item_deadline = AgendaItem:new(headline_deadline:get_all_dates()[1], headline_deadline, past_day) assert.is.True(agenda_item_deadline.is_valid) - assert.are.same( - { { hlgroup = hl_map.ok }, { hlgroup = hl_map.DONE, todo_keyword = 'DONE' } }, - agenda_item_deadline.highlights - ) + assert.is.same(hl_map.DONE, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.ok, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(past_day:format_time() .. '...... Deadline:', agenda_item_deadline.label) config:extend({ org_agenda_skip_deadline_if_done = true }) -- Scheduled done still showing assert.is.True(agenda_item.is_valid) - assert.are.same( - { { hlgroup = hl_map.ok }, { hlgroup = hl_map.DONE, todo_keyword = 'DONE' } }, - agenda_item.highlights - ) + assert.is.same(hl_map.DONE, agenda_item:get_todo_hlgroup()) + assert.is.same(hl_map.ok, agenda_item:get_hlgroup()) + assert.is.Nil(agenda_item:get_priority_hlgroup()) assert.are.same(past_day:format_time() .. '...... Scheduled:', agenda_item.label) -- Deadline done hidden agenda_item_deadline = AgendaItem:new(headline_deadline:get_all_dates()[1], headline_deadline, past_day) assert.is.False(agenda_item_deadline.is_valid) - assert.are.same({}, agenda_item_deadline.highlights) assert.are.same('', agenda_item_deadline.label) config:extend({ org_agenda_skip_deadline_if_done = false })