diff --git a/doc/orgmode.txt b/doc/orgmode.txt index 7508b63d4..ce9b54273 100644 --- a/doc/orgmode.txt +++ b/doc/orgmode.txt @@ -845,6 +845,59 @@ Separator used to separate multiple agenda views generated by `@org.agenda.separator` hl group. +org_agenda_prefix_format *orgmode-org_agenda_prefix_format* + +- Type: `{ agenda?: string, todo?: string, tags?: string, search?: string, + tags_todo?: string }` +- Default: + >lua + { + agenda = ' %-12:c%?t% s', + todo = ' %-12:c', + tags = ' %-12:c', + tags_todo = ' %-12:c', + search = ' %-12:c', + } + < + +Format for the prefix of each line in the agenda view. The format string can +contain placeholders that will be replaced with actual values. Format of the +placeholder: `%[specifiers][variable]` or `%[specifiers](lua expression)`. + +**Specifiers:** + +- `-` - Left align the value (default is right align) +- `[number]` - Set fixed width for the value +- `?` - Optional placeholder. If the value is empty, the placeholder (and surrounding spaces) will be omitted. + +**Variables:** + +- `c` - Category of the headline +- `:c` - Category of the headline followed by a colon +- `s` - Planning info (Deadline, Scheduled, etc.) +- `t` - Time range (e.g. 10:00-11:00) +- `T` - Tags +- `e` - Effort property +- `l` - Headline level +- `b` - Breadcrumbs (parent headline titles separated by `->`) + +**Lua expressions:** Anything inside `%()` will be evaluated as a Lua +expression. The expression has access to following variables: + +- `headline` - |orgmode-orgheadline| object +- `item` - |orgmode-orgagendaitem| object (only in `agenda` view) +- `metadata` - Metadata table containing `category_length` + +Example: + +>lua + org_agenda_prefix_format = { + agenda = ' %-12:c %?t %s ', + todo = ' %-12:c %(headline:get_property("PRIORITY") or "C") ' + } +< + + org_agenda_remove_tags *orgmode-org_agenda_remove_tags* - Type: `boolean` diff --git a/docs/configuration.org b/docs/configuration.org index da4ba9c4b..a089612b8 100644 --- a/docs/configuration.org +++ b/docs/configuration.org @@ -788,6 +788,56 @@ Separator used to separate multiple agenda views generated by [[#org_agenda_custom_commands][org_agenda_custom_commands]]. To change the highlight, override =@org.agenda.separator= hl group. +*** org_agenda_prefix_format +:PROPERTIES: +:CUSTOM_ID: org_agenda_prefix_format +:END: +- Type: ={ agenda?: string, todo?: string, tags?: string, search?: string, tags_todo?: string }= +- Default: + #+begin_src lua + { + agenda = ' %-12:c%?t% s', + todo = ' %-12:c', + tags = ' %-12:c', + tags_todo = ' %-12:c', + search = ' %-12:c', + } + #+end_src + +Format for the prefix of each line in the agenda view. +The format string can contain placeholders that will be replaced with actual values. +Format of the placeholder: =%[specifiers][variable]= or =%[specifiers](lua expression)=. + +*Specifiers:* +- =-= - Left align the value (default is right align) +- =[number]= - Set fixed width for the value +- =?= - Optional placeholder. If the value is empty, the placeholder (and surrounding spaces) will be omitted. + +*Variables:* +- =c= - Category of the headline +- =:c= - Category of the headline followed by a colon +- =s= - Planning info (Deadline, Scheduled, etc.) +- =t= - Time range (e.g. 10:00-11:00) +- =T= - Tags +- =e= - Effort property +- =l= - Headline level +- =b= - Breadcrumbs (parent headline titles separated by =->=) + +*Lua expressions:* +Anything inside =%()= will be evaluated as a Lua expression. +The expression has access to following variables: +- =headline= - [[#org-headline][OrgHeadline]] object +- =item= - [[#org-agenda-item][OrgAgendaItem]] object (only in =agenda= view) +- =metadata= - Metadata table containing =category_length= + +Example: +#+begin_src lua +org_agenda_prefix_format = { + agenda = ' %-12:c %?t %s ', + todo = ' %-12:c %(headline:get_property("PRIORITY") or "C") ' +} +#+end_src + *** org_agenda_remove_tags :PROPERTIES: :CUSTOM_ID: org_agenda_remove_tags diff --git a/lua/orgmode/agenda/agenda_item.lua b/lua/orgmode/agenda/agenda_item.lua index b68c4f493..4dd50e32e 100644 --- a/lua/orgmode/agenda/agenda_item.lua +++ b/lua/orgmode/agenda/agenda_item.lua @@ -20,6 +20,8 @@ end ---@field is_in_date_range boolean ---@field date_range_days number ---@field label string +---@field time string +---@field marker string ---@field index number local AgendaItem = {} @@ -77,6 +79,8 @@ function AgendaItem:_process() end function AgendaItem:_generate_data() + self.time = self.headline_date:has_time() and self:_format_time(self.headline_date) or '' + self.marker = self:_generate_marker() self.label = self:_generate_label() end @@ -152,19 +156,18 @@ function AgendaItem:_is_valid_for_date() return false end - -function AgendaItem:_generate_label() - local time = self.headline_date:has_time() and add_padding(self:_format_time(self.headline_date)) or '' +---@private +function AgendaItem:_generate_marker() if self.headline_date:is_deadline() then if self.is_same_day then - return time .. 'Deadline:' + return 'Deadline:' end return self.headline_date:humanize(self.date) .. ':' end if self.headline_date:is_scheduled() then if self.is_same_day then - return time .. 'Scheduled:' + return 'Scheduled:' end local diff = math.abs(self.date:diff(self.headline_date)) @@ -174,21 +177,30 @@ function AgendaItem:_generate_label() if self.headline_date.is_date_range_start then if not self.is_in_date_range then - return time + return '' end - local range = string.format('(%d/%d):', self.date:diff(self.headline_date) + 1, self.date_range_days) - if not self.is_same_day then - return range - end - return time .. range + return string.format('(%d/%d):', self.date:diff(self.headline_date) + 1, self.date_range_days) end if self.headline_date.is_date_range_end then - local range = string.format('(%d/%d):', self.date_range_days, self.date_range_days) - return time .. range + return string.format('(%d/%d):', self.date_range_days, self.date_range_days) + end + + return '' +end + +function AgendaItem:_generate_label() + local include_time = self.time ~= '' + if (self.headline_date:is_deadline() or self.headline_date:is_scheduled()) and not self.is_same_day then + include_time = false + end + + if self.headline_date.is_date_range_start and not self.is_in_date_range and not self.is_same_day then + include_time = false end - return time + local time = include_time and add_padding(self.time) or '' + return time .. self.marker end ---@private diff --git a/lua/orgmode/agenda/init.lua b/lua/orgmode/agenda/init.lua index 9b42d73cc..ab1d8cb23 100644 --- a/lua/orgmode/agenda/init.lua +++ b/lua/orgmode/agenda/init.lua @@ -122,7 +122,9 @@ function Agenda:_build_custom_commands() end local custom_commands = {} ---@param opts OrgAgendaCustomCommandType - local get_type_opts = function(opts, id) + ---@param parent_opts? table + local get_type_opts = function(opts, id, parent_opts) + parent_opts = parent_opts or {} local opts_by_type = { agenda = { span = opts.org_agenda_span, @@ -153,7 +155,9 @@ function Agenda:_build_custom_commands() opts_by_type[opts.type].tag_filter = opts.org_agenda_tag_filter_preset opts_by_type[opts.type].category_filter = opts.org_agenda_category_filter_preset opts_by_type[opts.type].highlighter = self.highlighter - opts_by_type[opts.type].remove_tags = opts.org_agenda_remove_tags + opts_by_type[opts.type].remove_tags = utils.if_nil(opts.org_agenda_remove_tags, parent_opts.org_agenda_remove_tags) + opts_by_type[opts.type].org_agenda_prefix_format = + utils.if_nil(opts.org_agenda_prefix_format, parent_opts.org_agenda_prefix_format) opts_by_type[opts.type].id = id return opts_by_type[opts.type] @@ -165,7 +169,7 @@ function Agenda:_build_custom_commands() action = function() local views = {} for i, agenda_type in ipairs(command.types) do - local opts = get_type_opts(agenda_type, ('%s_%s_%d'):format(shortcut, agenda_type.type, i)) + local opts = get_type_opts(agenda_type, ('%s_%s_%d'):format(shortcut, agenda_type.type, i), command) if not opts then utils.echo_error('Invalid custom agenda command type ' .. agenda_type.type) break diff --git a/lua/orgmode/agenda/types/agenda.lua b/lua/orgmode/agenda/types/agenda.lua index 56871e8a8..f49ffd426 100644 --- a/lua/orgmode/agenda/types/agenda.lua +++ b/lua/orgmode/agenda/types/agenda.lua @@ -6,6 +6,7 @@ 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 Formatter = require('orgmode.agenda.view.formatter') local ClockReport = require('orgmode.clock.report') local utils = require('orgmode.utils') local SortingStrategy = require('orgmode.agenda.sorting_strategy') @@ -52,6 +53,7 @@ local Promise = require('orgmode.utils.promise') ---@field sorting_strategy? OrgAgendaSortingStrategy[] ---@field remove_tags? boolean ---@field valid_filters? OrgAgendaFilter[] +---@field org_agenda_prefix_format? table ---@field id? string ---@field private _grid_times { hour: number, min: number }[] local OrgAgendaType = {} @@ -79,6 +81,7 @@ function OrgAgendaType:new(opts) sorting_strategy = opts.sorting_strategy or vim.tbl_get(config.org_agenda_sorting_strategy, 'agenda') or {}, id = opts.id, remove_tags = utils.if_nil(opts.remove_tags, config.org_agenda_remove_tags), + org_agenda_prefix_format = opts.org_agenda_prefix_format, } data.valid_filters = vim.tbl_filter(function(filter) return filter and true or false @@ -246,6 +249,10 @@ function OrgAgendaType:render(bufnr, current_line) end local agenda_days = self:_get_agenda_days() + local prefix_formats = self.org_agenda_prefix_format or config.org_agenda_prefix_format + local prefix_format = prefix_formats.agenda + local compiled_prefix = Formatter.compile(prefix_format) + local agendaView = AgendaView:new({ bufnr = self.bufnr, highlighter = self.highlighter }) agendaView:add_line(AgendaLine:single_token({ content = self:_get_title(), @@ -269,7 +276,7 @@ function OrgAgendaType:render(bufnr, current_line) for _, agenda_item in ipairs(agenda_day.agenda_items) do -- If there is an index value, this is an AgendaItem instance if agenda_item.index then - agendaView:add_line(self:_build_line(agenda_item, agenda_day)) + agendaView:add_line(self:_build_line(agenda_item, agenda_day, compiled_prefix)) else agendaView:add_line(self:_build_time_grid_line(agenda_item, agenda_day)) end @@ -496,8 +503,9 @@ end ---@private ---@param agenda_item OrgAgendaItem ---@param metadata table +---@param compiled_prefix? OrgAgendaFormatterSegment[] ---@return OrgAgendaLine -function OrgAgendaType:_build_line(agenda_item, metadata) +function OrgAgendaType:_build_line(agenda_item, metadata, compiled_prefix) local headline = agenda_item.headline local item_hl_group = agenda_item:get_hlgroup() local line = AgendaLine:new({ @@ -510,11 +518,13 @@ function OrgAgendaType:_build_line(agenda_item, metadata) label_length = metadata.label_length, }, }) + + local prefix_formats = self.org_agenda_prefix_format or config.org_agenda_prefix_format + local prefix_format = compiled_prefix or Formatter.compile(prefix_formats.agenda) + local prefix = Formatter.format(prefix_format, agenda_item, metadata) + 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), + content = prefix, })) local todo = headline:get_todo() if todo then diff --git a/lua/orgmode/agenda/types/search.lua b/lua/orgmode/agenda/types/search.lua index 54900d03e..6fbcad143 100644 --- a/lua/orgmode/agenda/types/search.lua +++ b/lua/orgmode/agenda/types/search.lua @@ -13,6 +13,7 @@ OrgAgendaSearchType.__index = OrgAgendaSearchType ---@param opts OrgAgendaSearchTypeOpts function OrgAgendaSearchType:new(opts) opts.todo_only = false + opts.prefix_key = 'search' opts.subheader = 'Press "r" to update search' setmetatable(self, { __index = OrgAgendaTodosType }) local obj = OrgAgendaTodosType:new(opts) diff --git a/lua/orgmode/agenda/types/tags.lua b/lua/orgmode/agenda/types/tags.lua index d58bc9600..9379292fe 100644 --- a/lua/orgmode/agenda/types/tags.lua +++ b/lua/orgmode/agenda/types/tags.lua @@ -24,6 +24,7 @@ OrgAgendaTagsType.__index = OrgAgendaTagsType ---@param opts OrgAgendaTagsTypeOpts function OrgAgendaTagsType:new(opts) opts.todo_only = opts.todo_only or false + opts.prefix_key = 'tags' opts.sorting_strategy = opts.sorting_strategy or vim.tbl_get(config.org_agenda_sorting_strategy, 'tags') or {} if not opts.id then opts.subheader = 'Press "r" to update search' diff --git a/lua/orgmode/agenda/types/tags_todo.lua b/lua/orgmode/agenda/types/tags_todo.lua index e38d32ef8..3a65924f8 100644 --- a/lua/orgmode/agenda/types/tags_todo.lua +++ b/lua/orgmode/agenda/types/tags_todo.lua @@ -13,6 +13,7 @@ function OrgAgendaTagsTodoType:new(opts) return nil end setmetatable(obj, self) + obj.prefix_key = 'tags_todo' return obj end diff --git a/lua/orgmode/agenda/types/todo.lua b/lua/orgmode/agenda/types/todo.lua index 7e8379c33..71827df62 100644 --- a/lua/orgmode/agenda/types/todo.lua +++ b/lua/orgmode/agenda/types/todo.lua @@ -4,6 +4,7 @@ local Files = require('orgmode.files') local AgendaLine = require('orgmode.agenda.view.line') local AgendaFilter = require('orgmode.agenda.filter') local AgendaLineToken = require('orgmode.agenda.view.token') +local Formatter = require('orgmode.agenda.view.formatter') local utils = require('orgmode.utils') local agenda_highlights = require('orgmode.colors.highlights') local hl_map = agenda_highlights.get_agenda_hl_map() @@ -41,6 +42,8 @@ local Promise = require('orgmode.utils.promise') ---@field remove_tags? boolean ---@field valid_filters OrgAgendaFilter[] ---@field id? string +---@field prefix_key string +---@field org_agenda_prefix_format? table local OrgAgendaTodosType = {} OrgAgendaTodosType.__index = OrgAgendaTodosType @@ -61,6 +64,8 @@ function OrgAgendaTodosType:new(opts) sorting_strategy = opts.sorting_strategy or vim.tbl_get(config.org_agenda_sorting_strategy, 'todo') or {}, id = opts.id, remove_tags = type(opts.remove_tags) == 'boolean' and opts.remove_tags or config.org_agenda_remove_tags, + prefix_key = opts.prefix_key or 'todo', + org_agenda_prefix_format = opts.org_agenda_prefix_format, }, OrgAgendaTodosType) this.valid_filters = vim.tbl_filter(function(filter) return filter and true or false @@ -103,9 +108,20 @@ function OrgAgendaTodosType:_get_header() end ---@param bufnr? number -function OrgAgendaTodosType:render(bufnr) +---@param current_line? number +function OrgAgendaTodosType:render(bufnr, current_line) self.bufnr = bufnr or 0 + local was_line_in_view = true + if self.view then + was_line_in_view = self.view:is_in_range(current_line) + end + local headlines, category_length = self:_get_headlines() + + local prefix_formats = self.org_agenda_prefix_format or config.org_agenda_prefix_format + local prefix_format = prefix_formats[self.prefix_key] or prefix_formats.todo + local compiled_prefix = Formatter.compile(prefix_format) + local agendaView = AgendaView:new({ bufnr = self.bufnr, highlighter = self.highlighter }) -- If custom view and no headlines, return empty view @@ -127,25 +143,51 @@ function OrgAgendaTodosType:render(bufnr) end for _, headline in ipairs(headlines) do - agendaView:add_line(self:_build_line(headline, { category_length = category_length })) + agendaView:add_line(self:_build_line(headline, { category_length = category_length }, compiled_prefix)) end self.view = agendaView:render() + if was_line_in_view then + self:_jump_to_item(current_line) + end return self.view end +---@private +function OrgAgendaTodosType:_jump_to_item(line) + local agenda_line = self:get_line(line) + if not agenda_line or not agenda_line.headline then + return + end + local target_headline = agenda_line.headline + local target_file = target_headline.file.filename + local target_id = target_headline.headline:id() + + for _, l in ipairs(self.view.lines) do + if l.headline and l.headline.file.filename == target_file and l.headline.headline:id() == target_id then + return vim.fn.cursor({ l.line_nr, 0 }) + end + end +end + ---@private ---@param headline OrgHeadline ---@param metadata table +---@param compiled_prefix? OrgAgendaFormatterSegment[] ---@return OrgAgendaLine -function OrgAgendaTodosType:_build_line(headline, metadata) +function OrgAgendaTodosType:_build_line(headline, metadata, compiled_prefix) local line = AgendaLine:new({ headline = headline, line_hl_group = headline:is_clocked_in() and 'Visual' or nil, metadata = metadata, }) + + local prefix_formats = self.org_agenda_prefix_format or config.org_agenda_prefix_format + local prefix_format = compiled_prefix or Formatter.compile(prefix_formats[self.prefix_key] or prefix_formats.todo) + local prefix = Formatter.format(prefix_format, nil, metadata, headline) + line:add_token(AgendaLineToken:new({ - content = ' ' .. utils.pad_right(('%s:'):format(headline:get_category()), metadata.category_length), + content = prefix, })) local todo, _, todo_type = headline:get_todo() diff --git a/lua/orgmode/agenda/view/formatter.lua b/lua/orgmode/agenda/view/formatter.lua new file mode 100644 index 000000000..383fed24e --- /dev/null +++ b/lua/orgmode/agenda/view/formatter.lua @@ -0,0 +1,195 @@ +local utils = require('orgmode.utils') + +---@class OrgAgendaFormatterSegment +---@field type 'literal' | 'placeholder' +---@field value? string literal value +---@field var? string variable name +---@field func? function compiled expression or variable fetcher +---@field optional? boolean +---@field alignment? '-' | '' +---@field width? number +---@field spaces? string + +---@class OrgAgendaFormatter +---@field private compiled_cache table +local Formatter = { + compiled_cache = {}, +} + +local builtin_vars = { + c = function(headline) + return headline:get_category() + end, + [':c'] = function(headline) + local cat = headline:get_category() + return cat ~= '' and (cat .. ':') or '' + end, + s = function(headline, agenda_item) + -- Marker for planning info (Deadline, Scheduled, etc.) + return (agenda_item and agenda_item.marker) or '' + end, + t = function(headline, agenda_item) + return (agenda_item and agenda_item.time) or '' + end, + i = function() + return '' + end, + T = function(headline) + local tags = headline:get_tags() + return #tags > 0 and headline:tags_to_string() or '' + end, + e = function(headline) + return headline:get_property('effort') or '' + end, + l = function(headline) + return tostring(headline:get_level()) + end, + b = function(headline) + -- Breadcrumbs: build from parent headlines + local breadcrumbs = {} + local parent = headline.parent + while parent and parent.level > 0 do + table.insert(breadcrumbs, 1, parent:get_title()) + parent = parent.parent + end + return table.concat(breadcrumbs, '->') + end, +} + +--- Apply formatting specifiers (width, alignment) to a value +---@param value string +---@param segment OrgAgendaFormatterSegment +---@param metadata table +---@return string +local function apply_formatting(value, segment, metadata) + local w = segment.width + -- Fallback for category width if not specified + if not w and (segment.var == 'c' or segment.var == ':c') then + w = metadata.category_length + end + + if not w then + return value + end + + if segment.alignment == '-' then + return utils.pad_right(value, w) + end + return utils.pad_left(value, w) +end + +---@param format_string string +---@return OrgAgendaFormatterSegment[] +function Formatter.compile(format_string) + if Formatter.compiled_cache[format_string] then + return Formatter.compiled_cache[format_string] + end + + local segments = {} + local pos = 1 + -- pattern_var matches % [optional flags/width] [variable name] + local pattern_var = '%%([%?%-?%d%s]*)([:%a]+)' + -- pattern_expr matches % [optional flags/width] (Lua expression) + local pattern_expr = '%%([%?%-?%d%s]*)(%b())' + + while pos <= #format_string do + local s_var, e_var, spec_var, var = format_string:find(pattern_var, pos) + local s_expr, e_expr, spec_expr, expr = format_string:find(pattern_expr, pos) + + local start_pos, end_pos, specifiers, value, is_expr + -- Determine which pattern matches first + if s_var and (not s_expr or s_var < s_expr) then + start_pos, end_pos, specifiers, value, is_expr = s_var, e_var, spec_var, var, false + elseif s_expr then + start_pos, end_pos, specifiers, value, is_expr = s_expr, e_expr, spec_expr, expr, true + end + + -- If no more placeholders, append the rest of the string as literal + if not start_pos then + table.insert(segments, { type = 'literal', value = format_string:sub(pos) }) + break + end + + -- Append literal text before the placeholder + if start_pos > pos then + table.insert(segments, { type = 'literal', value = format_string:sub(pos, start_pos - 1) }) + end + + local segment = { + type = 'placeholder', + optional = specifiers:match('%?') ~= nil, + alignment = specifiers:match('%-') or '', + width = tonumber(specifiers:match('%d+')), + spaces = specifiers:match('^%s+') or specifiers:match('%s+$') or '', + var = value, + } + + if is_expr then + local lua_code = value:sub(2, -2) + local f = (loadstring or load)('return ' .. lua_code) + if f then + segment.func = function(headline, agenda_item, metadata) + local env = setmetatable({ + headline = headline, + item = agenda_item, + metadata = metadata, + }, { __index = _G }) + if setfenv then + setfenv(f, env) + end + local ok, res = pcall(f) + return ok and tostring(res or '') or '' + end + end + elseif builtin_vars[value] then + local builtin_func = builtin_vars[value] + segment.func = function(headline, agenda_item) + return builtin_func(headline, agenda_item) + end + else + segment.func = function() + return '' + end + end + + table.insert(segments, segment) + pos = end_pos + 1 + end + + Formatter.compiled_cache[format_string] = segments + return segments +end + +---@param format_string string | OrgAgendaFormatterSegment[] +---@param agenda_item? OrgAgendaItem +---@param metadata table +---@param headline? OrgHeadline +---@return string +function Formatter.format(format_string, agenda_item, metadata, headline) + local segments = type(format_string) == 'string' and Formatter.compile(format_string) or format_string + headline = headline or (agenda_item and agenda_item.headline) + if not headline then + return '' + end + + local result = '' + for _, segment in ipairs(segments) do + if segment.type == 'literal' then + result = result .. segment.value + else + local value = segment.func(headline, agenda_item, metadata) + + -- Handle optional placeholders and leading/trailing spaces + if (segment.optional or segment.spaces ~= '') and value == '' then + -- Skip empty optional values + else + local formatted_value = apply_formatting(value, segment, metadata) + result = result .. segment.spaces .. formatted_value + end + end + end + + return result +end + +return Formatter diff --git a/lua/orgmode/config/defaults.lua b/lua/orgmode/config/defaults.lua index 2a5ebd64f..b20e3a5f2 100644 --- a/lua/orgmode/config/defaults.lua +++ b/lua/orgmode/config/defaults.lua @@ -26,6 +26,13 @@ local DefaultConfig = { org_agenda_skip_deadline_if_done = false, org_agenda_text_search_extra_files = {}, org_agenda_custom_commands = {}, + org_agenda_prefix_format = { + agenda = ' %-12:c%?t% s', + todo = ' %-12:c', + tags = ' %-12:c', + tags_todo = ' %-12:c', + search = ' %-12:c', + }, org_agenda_hide_empty_blocks = false, org_agenda_block_separator = '-', org_agenda_sorting_strategy = { diff --git a/lua/orgmode/utils/init.lua b/lua/orgmode/utils/init.lua index 23808316f..9acc38ccc 100644 --- a/lua/orgmode/utils/init.lua +++ b/lua/orgmode/utils/init.lua @@ -485,6 +485,16 @@ function utils.pad_right(str, amount) return string.format('%s%s', str, string.rep(' ', spaces)) end +---@param str string +---@param amount number +function utils.pad_left(str, amount) + local spaces = math.max(0, amount - vim.api.nvim_strwidth(str)) + if spaces == 0 then + return str + end + return string.format('%s%s', string.rep(' ', spaces), str) +end + function utils.is_list(value) if vim.islist then return vim.islist(value) diff --git a/tests/plenary/agenda/formatter_spec.lua b/tests/plenary/agenda/formatter_spec.lua new file mode 100644 index 000000000..b657297d9 --- /dev/null +++ b/tests/plenary/agenda/formatter_spec.lua @@ -0,0 +1,129 @@ +local Formatter = require('orgmode.agenda.view.formatter') +local AgendaItem = require('orgmode.agenda.agenda_item') +local Date = require('orgmode.objects.date') +local helpers = require('tests.plenary.helpers') + +describe('Agenda formatter', function() + local function generate_headline(content_line, title) + title = title or 'This is some content' + local file = helpers.create_file({ + '* TODO ' .. title, + content_line, + }, 'agenda_test.org') + return file:get_headlines()[1] + end + + it('should format category correctly', function() + local today = Date.now() + local headline = generate_headline(string.format('<%s>', today:to_string())) + local agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) + local metadata = { category_length = 10 } + + local format = '%c' + local result = Formatter.format(format, agenda_item, metadata) + assert.are.same('agenda_test', result) + + format = '%-12c' + result = Formatter.format(format, agenda_item, metadata) + assert.are.same('agenda_test ', result) -- agenda_test is 11 chars + + format = '%:c' + result = Formatter.format(format, agenda_item, metadata) + assert.are.same('agenda_test:', result) + end) + + it('should format time and scheduling correctly', function() + local today = Date.now():set({ hour = 10, min = 0 }) + local headline = generate_headline(string.format('<%s>', today:to_string())) + local agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) + local metadata = { category_length = 10 } + + local format = '%t' + local result = Formatter.format(format, agenda_item, metadata) + assert.are.same('10:00', result) + + headline = generate_headline(string.format('DEADLINE: <%s>', today:to_string())) + agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) + format = '%s' + result = Formatter.format(format, agenda_item, metadata) + assert.are.same('Deadline:', result) + end) + + it('should handle optional formatting with %?', function() + local today = Date.now():set({ hour = 10, min = 0 }) + local headline = generate_headline(string.format('<%s>', today:to_string())) + local agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) + local metadata = { category_length = 10 } + + local format = '%?s' + local result = Formatter.format(format, agenda_item, metadata) + assert.are.same('', result) -- s is empty + + headline = generate_headline(string.format('DEADLINE: <%s>', today:to_string())) + agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) + result = Formatter.format(format, agenda_item, metadata) + assert.are.same('Deadline:', result) + end) + + it('should handle optional space in format string', function() + local today = Date.now():set({ hour = 10, min = 0 }) + local headline = generate_headline(string.format('<%s>', today:to_string())) + local agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) + local metadata = { category_length = 10 } + + local format = '% s' + local result = Formatter.format(format, agenda_item, metadata) + assert.are.same('', result) -- s is empty + + headline = generate_headline(string.format('DEADLINE: <%s>', today:to_string())) + agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) + result = Formatter.format(format, agenda_item, metadata) + assert.are.same(' Deadline:', result) -- notice the space + end) + + it('should support evaluating expressions with %(...)', function() + local today = Date.now() + local headline = generate_headline(string.format('<%s>', today:to_string())) + local agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) + local metadata = { category_length = 10 } + + -- Define a global function for testing + _G.test_prefix = function() + return 'SHORT' + end + + local format = '%(test_prefix())' + local result = Formatter.format(format, agenda_item, metadata) + assert.are.same('SHORT', result) + + -- Test with width and alignment + format = '%10(test_prefix())' + result = Formatter.format(format, agenda_item, metadata) + assert.are.same(' SHORT', result) + + -- Test access to headline in the environment + format = '%(headline:get_category())' + result = Formatter.format(format, agenda_item, metadata) + assert.are.same('agenda_test', result) + + -- Test access to item marker + format = '%(item.marker)' + headline = generate_headline(string.format('DEADLINE: <%s>', today:to_string())) + agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) + result = Formatter.format(format, agenda_item, metadata) + assert.are.same('Deadline:', result) + + _G.test_prefix = nil + end) + + it('should handle multiple placeholders', function() + local today = Date.now():set({ hour = 10, min = 0 }) + local headline = generate_headline(string.format('DEADLINE: <%s>', today:to_string())) + local agenda_item = AgendaItem:new(headline:get_all_dates()[1], headline, today) + local metadata = { category_length = 10 } + + local format = '%c %t %s' + local result = Formatter.format(format, agenda_item, metadata) + assert.are.same('agenda_test 10:00 Deadline:', result) + end) +end)