From 3566f021b61979c106902f86974d063bf1bbe6f7 Mon Sep 17 00:00:00 2001 From: Swapnil Mahajan Date: Thu, 2 Apr 2026 10:59:53 +0530 Subject: [PATCH 1/8] feat(agenda): implement org_agenda_prefix_format --- lua/orgmode/agenda/agenda_item.lua | 38 ++++++---- lua/orgmode/agenda/types/agenda.lua | 10 +-- lua/orgmode/agenda/types/search.lua | 1 + lua/orgmode/agenda/types/tags.lua | 1 + lua/orgmode/agenda/types/todo.lua | 9 ++- lua/orgmode/agenda/view/formatter.lua | 96 +++++++++++++++++++++++++ lua/orgmode/config/defaults.lua | 6 ++ lua/orgmode/utils/init.lua | 10 +++ tests/plenary/agenda/formatter_spec.lua | 94 ++++++++++++++++++++++++ 9 files changed, 247 insertions(+), 18 deletions(-) create mode 100644 lua/orgmode/agenda/view/formatter.lua create mode 100644 tests/plenary/agenda/formatter_spec.lua diff --git a/lua/orgmode/agenda/agenda_item.lua b/lua/orgmode/agenda/agenda_item.lua index b68c4f493..3dc853abe 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 extra 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.extra = self:_generate_extra() self.label = self:_generate_label() end @@ -153,18 +157,17 @@ 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 '' +function AgendaItem:_generate_extra() 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 - 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 + return '' 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.extra end ---@private diff --git a/lua/orgmode/agenda/types/agenda.lua b/lua/orgmode/agenda/types/agenda.lua index 56871e8a8..8436fcbcb 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') @@ -510,11 +511,12 @@ function OrgAgendaType:_build_line(agenda_item, metadata) label_length = metadata.label_length, }, }) + + local prefix_format = config.org_agenda_prefix_format.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/todo.lua b/lua/orgmode/agenda/types/todo.lua index 7e8379c33..c28d0124e 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,7 @@ local Promise = require('orgmode.utils.promise') ---@field remove_tags? boolean ---@field valid_filters OrgAgendaFilter[] ---@field id? string +---@field prefix_key string local OrgAgendaTodosType = {} OrgAgendaTodosType.__index = OrgAgendaTodosType @@ -61,6 +63,7 @@ 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', }, OrgAgendaTodosType) this.valid_filters = vim.tbl_filter(function(filter) return filter and true or false @@ -144,8 +147,12 @@ function OrgAgendaTodosType:_build_line(headline, metadata) line_hl_group = headline:is_clocked_in() and 'Visual' or nil, metadata = metadata, }) + + local prefix_format = config.org_agenda_prefix_format[self.prefix_key] or config.org_agenda_prefix_format.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..7dbafa512 --- /dev/null +++ b/lua/orgmode/agenda/view/formatter.lua @@ -0,0 +1,96 @@ +local utils = require('orgmode.utils') + +---@class OrgAgendaFormatter +local Formatter = {} + +---@param format_string string +---@param agenda_item? OrgAgendaItem +---@param metadata table +---@param headline? OrgHeadline +---@return string +function Formatter.format(format_string, agenda_item, metadata, headline) + headline = headline or (agenda_item and agenda_item.headline) + if not headline then return '' end + + local vars = { + c = function() return headline:get_category() end, + [':c'] = function() + local cat = headline:get_category() + return cat ~= '' and (cat .. ':') or '' + end, + s = function() return (agenda_item and agenda_item.extra) or '' end, + t = function() return (agenda_item and agenda_item.time) or '' end, + i = function() return '' end, + T = function() + local tags = headline:get_tags() + return #tags > 0 and headline:tags_to_string() or '' + end, + e = function() return headline:get_property('effort') or '' end, + l = function() return tostring(headline:get_level()) end, + b = function() + -- 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, + } + + -- Regex to match format specifiers: % [optional ?] [optional -] [optional number] [var] + -- vars can be :c or single char + local pos = 1 + local result_content = '' + + while pos <= #format_string do + local start_pos, end_pos, optional, alignment, width, spaces, var = format_string:find('%%(%??)(%-?)(%d*)(%s*)([:%a]+)', pos) + if not start_pos then + result_content = result_content .. format_string:sub(pos) + break + end + + result_content = result_content .. format_string:sub(pos, start_pos - 1) + + local var_key = var + if not vars[var_key] and #var_key > 1 then + -- Try to match only the first part if it's a known var (e.g. :c is handled, but others might be single char) + if vars[var_key:sub(1, 1)] then + var_key = var_key:sub(1, 1) + -- adjust end_pos back + end_pos = start_pos + #optional + #alignment + #width + #spaces + #var_key + end + end + + local value = '' + if vars[var_key] then + value = vars[var_key]() + end + + if (optional == '?' or spaces ~= '') and value == '' then + -- Skip + else + local w = tonumber(width) + if not w then + if var_key == 'c' or var_key == ':c' then + w = metadata.category_length + end + end + if w then + if alignment == '-' then + value = utils.pad_right(value, w) + else + value = utils.pad_left(value, w) + end + end + result_content = result_content .. spaces .. value + end + + pos = end_pos + 1 + end + + return result_content +end + +return Formatter diff --git a/lua/orgmode/config/defaults.lua b/lua/orgmode/config/defaults.lua index 2a5ebd64f..8137c527d 100644 --- a/lua/orgmode/config/defaults.lua +++ b/lua/orgmode/config/defaults.lua @@ -26,6 +26,12 @@ 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', + 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..9bba03c62 --- /dev/null +++ b/tests/plenary/agenda/formatter_spec.lua @@ -0,0 +1,94 @@ +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 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) From 8d7485cd7f3b60103bcbd5a7b35ccbb675d1d394 Mon Sep 17 00:00:00 2001 From: Swapnil Mahajan Date: Thu, 2 Apr 2026 11:06:52 +0530 Subject: [PATCH 2/8] feat(agenda): support Lua expressions in org_agenda_prefix_format --- lua/orgmode/agenda/view/formatter.lua | 55 +++++++++++++++++++------ tests/plenary/agenda/formatter_spec.lua | 28 +++++++++++++ 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/lua/orgmode/agenda/view/formatter.lua b/lua/orgmode/agenda/view/formatter.lua index 7dbafa512..0b7ee7903 100644 --- a/lua/orgmode/agenda/view/formatter.lua +++ b/lua/orgmode/agenda/view/formatter.lua @@ -45,7 +45,20 @@ function Formatter.format(format_string, agenda_item, metadata, headline) local result_content = '' while pos <= #format_string do - local start_pos, end_pos, optional, alignment, width, spaces, var = format_string:find('%%(%??)(%-?)(%d*)(%s*)([:%a]+)', pos) + local start_pos, end_pos, prefix, var = format_string:find('%%([%?%-?%d%s]*)([:%a]+)', pos) + local is_expr = false + if not start_pos then + start_pos, end_pos, prefix, var = format_string:find('%%([%?%-?%d%s]*)(%b())', pos) + is_expr = true + else + -- Check if there is an expression before the found variable + local s2, e2, p2, v2 = format_string:find('%%([%?%-?%d%s]*)(%b())', pos) + if s2 and s2 < start_pos then + start_pos, end_pos, prefix, var = s2, e2, p2, v2 + is_expr = true + end + end + if not start_pos then result_content = result_content .. format_string:sub(pos) break @@ -53,19 +66,35 @@ function Formatter.format(format_string, agenda_item, metadata, headline) result_content = result_content .. format_string:sub(pos, start_pos - 1) - local var_key = var - if not vars[var_key] and #var_key > 1 then - -- Try to match only the first part if it's a known var (e.g. :c is handled, but others might be single char) - if vars[var_key:sub(1, 1)] then - var_key = var_key:sub(1, 1) - -- adjust end_pos back - end_pos = start_pos + #optional + #alignment + #width + #spaces + #var_key - end - end + local optional = prefix:match('%?') or '' + local alignment = prefix:match('%-') or '' + local width = prefix:match('%d+') or '' + local spaces = prefix:match('^%s+') or prefix:match('%s+$') or '' local value = '' - if vars[var_key] then - value = vars[var_key]() + if is_expr then + local expr = var:sub(2, -2) + local env = { + headline = headline, + item = agenda_item, + metadata = metadata, + } + setmetatable(env, { __index = _G }) + local f, err = (loadstring or load)('return ' .. expr) + if f then + if setfenv then + setfenv(f, env) + end + local ok, res = pcall(f, env) + if ok then + value = tostring(res or '') + end + end + elseif vars[var] then + value = vars[var]() + elseif #var > 1 and vars[var:sub(1, 1)] then + -- Handle cases like :c if needed, but our vars table already has :c + value = vars[var]() end if (optional == '?' or spaces ~= '') and value == '' then @@ -73,7 +102,7 @@ function Formatter.format(format_string, agenda_item, metadata, headline) else local w = tonumber(width) if not w then - if var_key == 'c' or var_key == ':c' then + if var == 'c' or var == ':c' then w = metadata.category_length end end diff --git a/tests/plenary/agenda/formatter_spec.lua b/tests/plenary/agenda/formatter_spec.lua index 9bba03c62..e98798fcd 100644 --- a/tests/plenary/agenda/formatter_spec.lua +++ b/tests/plenary/agenda/formatter_spec.lua @@ -81,6 +81,34 @@ describe('Agenda formatter', function() 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) + + _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())) From 0db8c88adad1f707a7fe96a7d8acbcc97cb45000 Mon Sep 17 00:00:00 2001 From: Swapnil Mahajan Date: Thu, 2 Apr 2026 12:40:47 +0530 Subject: [PATCH 3/8] fix(agenda): support org_agenda_prefix_format in custom commands --- lua/orgmode/agenda/init.lua | 1 + lua/orgmode/agenda/types/agenda.lua | 5 ++++- lua/orgmode/agenda/types/todo.lua | 5 ++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lua/orgmode/agenda/init.lua b/lua/orgmode/agenda/init.lua index 9b42d73cc..201b422ee 100644 --- a/lua/orgmode/agenda/init.lua +++ b/lua/orgmode/agenda/init.lua @@ -154,6 +154,7 @@ function Agenda:_build_custom_commands() 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].org_agenda_prefix_format = opts.org_agenda_prefix_format opts_by_type[opts.type].id = id return opts_by_type[opts.type] diff --git a/lua/orgmode/agenda/types/agenda.lua b/lua/orgmode/agenda/types/agenda.lua index 8436fcbcb..d8d14b800 100644 --- a/lua/orgmode/agenda/types/agenda.lua +++ b/lua/orgmode/agenda/types/agenda.lua @@ -53,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 = {} @@ -80,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 @@ -512,7 +514,8 @@ function OrgAgendaType:_build_line(agenda_item, metadata) }, }) - local prefix_format = config.org_agenda_prefix_format.agenda + local prefix_formats = self.org_agenda_prefix_format or config.org_agenda_prefix_format + local prefix_format = prefix_formats.agenda local prefix = Formatter.format(prefix_format, agenda_item, metadata) line:add_token(AgendaLineToken:new({ diff --git a/lua/orgmode/agenda/types/todo.lua b/lua/orgmode/agenda/types/todo.lua index c28d0124e..53edb511f 100644 --- a/lua/orgmode/agenda/types/todo.lua +++ b/lua/orgmode/agenda/types/todo.lua @@ -43,6 +43,7 @@ local Promise = require('orgmode.utils.promise') ---@field valid_filters OrgAgendaFilter[] ---@field id? string ---@field prefix_key string +---@field org_agenda_prefix_format? table local OrgAgendaTodosType = {} OrgAgendaTodosType.__index = OrgAgendaTodosType @@ -64,6 +65,7 @@ function OrgAgendaTodosType:new(opts) 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 @@ -148,7 +150,8 @@ function OrgAgendaTodosType:_build_line(headline, metadata) metadata = metadata, }) - local prefix_format = config.org_agenda_prefix_format[self.prefix_key] or config.org_agenda_prefix_format.todo + 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 prefix = Formatter.format(prefix_format, nil, metadata, headline) line:add_token(AgendaLineToken:new({ From ef0655c053d363ef9296dd7fd7b936a4371a9d3e Mon Sep 17 00:00:00 2001 From: Swapnil Mahajan Date: Thu, 2 Apr 2026 12:43:31 +0530 Subject: [PATCH 4/8] fix(agenda): support command-level org_agenda_prefix_format in custom commands --- lua/orgmode/agenda/init.lua | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lua/orgmode/agenda/init.lua b/lua/orgmode/agenda/init.lua index 201b422ee..3f3d470b3 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,8 +155,8 @@ 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].org_agenda_prefix_format = opts.org_agenda_prefix_format + 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] @@ -166,7 +168,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 From 7b6d3219272601acda7e9177766659a838168682 Mon Sep 17 00:00:00 2001 From: Swapnil Mahajan Date: Thu, 2 Apr 2026 13:25:42 +0530 Subject: [PATCH 5/8] fix(agenda): support tags_todo in org_agenda_prefix_format --- lua/orgmode/agenda/types/tags_todo.lua | 1 + lua/orgmode/config/defaults.lua | 1 + 2 files changed, 2 insertions(+) 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/config/defaults.lua b/lua/orgmode/config/defaults.lua index 8137c527d..b20e3a5f2 100644 --- a/lua/orgmode/config/defaults.lua +++ b/lua/orgmode/config/defaults.lua @@ -30,6 +30,7 @@ local DefaultConfig = { agenda = ' %-12:c%?t% s', todo = ' %-12:c', tags = ' %-12:c', + tags_todo = ' %-12:c', search = ' %-12:c', }, org_agenda_hide_empty_blocks = false, From e26dcf3c9c3fb429c63a801da1ee7d53e3911d41 Mon Sep 17 00:00:00 2001 From: Swapnil Mahajan Date: Fri, 3 Apr 2026 11:12:04 +0530 Subject: [PATCH 6/8] refactor(agenda): optimize prefix formatting with pre-compilation and rename extra to marker for clarity --- lua/orgmode/agenda/agenda_item.lua | 11 +- lua/orgmode/agenda/types/agenda.lua | 11 +- lua/orgmode/agenda/types/todo.lua | 12 +- lua/orgmode/agenda/view/formatter.lua | 256 +++++++++++++++--------- tests/plenary/agenda/formatter_spec.lua | 7 + 5 files changed, 193 insertions(+), 104 deletions(-) diff --git a/lua/orgmode/agenda/agenda_item.lua b/lua/orgmode/agenda/agenda_item.lua index 3dc853abe..9824bef81 100644 --- a/lua/orgmode/agenda/agenda_item.lua +++ b/lua/orgmode/agenda/agenda_item.lua @@ -21,7 +21,7 @@ end ---@field date_range_days number ---@field label string ---@field time string ----@field extra string +---@field marker string ---@field index number local AgendaItem = {} @@ -80,7 +80,7 @@ end function AgendaItem:_generate_data() self.time = self.headline_date:has_time() and self:_format_time(self.headline_date) or '' - self.extra = self:_generate_extra() + self.marker = self:_generate_marker() self.label = self:_generate_label() end @@ -156,8 +156,8 @@ function AgendaItem:_is_valid_for_date() return false end - -function AgendaItem:_generate_extra() +---@private +function AgendaItem:_generate_marker() if self.headline_date:is_deadline() then if self.is_same_day then return 'Deadline:' @@ -189,6 +189,7 @@ function AgendaItem:_generate_extra() 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 @@ -200,7 +201,7 @@ function AgendaItem:_generate_label() end local time = include_time and add_padding(self.time) or '' - return time .. self.extra + return time .. self.marker end ---@private diff --git a/lua/orgmode/agenda/types/agenda.lua b/lua/orgmode/agenda/types/agenda.lua index d8d14b800..f49ffd426 100644 --- a/lua/orgmode/agenda/types/agenda.lua +++ b/lua/orgmode/agenda/types/agenda.lua @@ -249,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(), @@ -272,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 @@ -499,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({ @@ -515,7 +520,7 @@ function OrgAgendaType:_build_line(agenda_item, metadata) }) local prefix_formats = self.org_agenda_prefix_format or config.org_agenda_prefix_format - local prefix_format = prefix_formats.agenda + 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({ diff --git a/lua/orgmode/agenda/types/todo.lua b/lua/orgmode/agenda/types/todo.lua index 53edb511f..a613ec8c4 100644 --- a/lua/orgmode/agenda/types/todo.lua +++ b/lua/orgmode/agenda/types/todo.lua @@ -111,6 +111,11 @@ end function OrgAgendaTodosType:render(bufnr) self.bufnr = bufnr or 0 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 @@ -132,7 +137,7 @@ 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() @@ -142,8 +147,9 @@ 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, @@ -151,7 +157,7 @@ function OrgAgendaTodosType:_build_line(headline, metadata) }) 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 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({ diff --git a/lua/orgmode/agenda/view/formatter.lua b/lua/orgmode/agenda/view/formatter.lua index 0b7ee7903..383fed24e 100644 --- a/lua/orgmode/agenda/view/formatter.lua +++ b/lua/orgmode/agenda/view/formatter.lua @@ -1,125 +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 -local Formatter = {} +---@field private compiled_cache table +local Formatter = { + compiled_cache = {}, +} ----@param format_string string ----@param agenda_item? OrgAgendaItem +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 ----@param headline? OrgHeadline ---@return string -function Formatter.format(format_string, agenda_item, metadata, headline) - headline = headline or (agenda_item and agenda_item.headline) - if not headline then return '' end - - local vars = { - c = function() return headline:get_category() end, - [':c'] = function() - local cat = headline:get_category() - return cat ~= '' and (cat .. ':') or '' - end, - s = function() return (agenda_item and agenda_item.extra) or '' end, - t = function() return (agenda_item and agenda_item.time) or '' end, - i = function() return '' end, - T = function() - local tags = headline:get_tags() - return #tags > 0 and headline:tags_to_string() or '' - end, - e = function() return headline:get_property('effort') or '' end, - l = function() return tostring(headline:get_level()) end, - b = function() - -- 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, - } +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 - -- Regex to match format specifiers: % [optional ?] [optional -] [optional number] [var] - -- vars can be :c or single char + 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 - local result_content = '' + -- 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 start_pos, end_pos, prefix, var = format_string:find('%%([%?%-?%d%s]*)([:%a]+)', pos) - local is_expr = false - if not start_pos then - start_pos, end_pos, prefix, var = format_string:find('%%([%?%-?%d%s]*)(%b())', pos) - is_expr = true - else - -- Check if there is an expression before the found variable - local s2, e2, p2, v2 = format_string:find('%%([%?%-?%d%s]*)(%b())', pos) - if s2 and s2 < start_pos then - start_pos, end_pos, prefix, var = s2, e2, p2, v2 - is_expr = true - end + 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 - result_content = result_content .. format_string:sub(pos) + table.insert(segments, { type = 'literal', value = format_string:sub(pos) }) break end - result_content = result_content .. format_string:sub(pos, start_pos - 1) + -- 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 optional = prefix:match('%?') or '' - local alignment = prefix:match('%-') or '' - local width = prefix:match('%d+') or '' - local spaces = prefix:match('^%s+') or prefix:match('%s+$') or '' + 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, + } - local value = '' if is_expr then - local expr = var:sub(2, -2) - local env = { - headline = headline, - item = agenda_item, - metadata = metadata, - } - setmetatable(env, { __index = _G }) - local f, err = (loadstring or load)('return ' .. expr) + local lua_code = value:sub(2, -2) + local f = (loadstring or load)('return ' .. lua_code) if f then - if setfenv then - setfenv(f, env) - end - local ok, res = pcall(f, env) - if ok then - value = tostring(res or '') + 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 vars[var] then - value = vars[var]() - elseif #var > 1 and vars[var:sub(1, 1)] then - -- Handle cases like :c if needed, but our vars table already has :c - value = vars[var]() - end - - if (optional == '?' or spaces ~= '') and value == '' then - -- Skip - else - local w = tonumber(width) - if not w then - if var == 'c' or var == ':c' then - w = metadata.category_length - 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 - if w then - if alignment == '-' then - value = utils.pad_right(value, w) - else - value = utils.pad_left(value, w) - end + else + segment.func = function() + return '' end - result_content = result_content .. spaces .. value end + table.insert(segments, segment) pos = end_pos + 1 end - return result_content + 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/tests/plenary/agenda/formatter_spec.lua b/tests/plenary/agenda/formatter_spec.lua index e98798fcd..49688fd92 100644 --- a/tests/plenary/agenda/formatter_spec.lua +++ b/tests/plenary/agenda/formatter_spec.lua @@ -106,6 +106,13 @@ describe('Agenda formatter', function() 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) From 3cbc864e92a0612bee1f9e32de996c3075ab0318 Mon Sep 17 00:00:00 2001 From: Swapnil Mahajan Date: Fri, 3 Apr 2026 11:15:55 +0530 Subject: [PATCH 7/8] docs: document org_agenda_prefix_format --- doc/orgmode.txt | 53 +++++++++++++++++++++++++ docs/configuration.org | 50 +++++++++++++++++++++++ lua/orgmode/agenda/agenda_item.lua | 1 - lua/orgmode/agenda/init.lua | 3 +- lua/orgmode/agenda/types/todo.lua | 24 ++++++++++- tests/plenary/agenda/formatter_spec.lua | 8 ++-- 6 files changed, 132 insertions(+), 7 deletions(-) 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 9824bef81..4dd50e32e 100644 --- a/lua/orgmode/agenda/agenda_item.lua +++ b/lua/orgmode/agenda/agenda_item.lua @@ -189,7 +189,6 @@ function AgendaItem:_generate_marker() 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 diff --git a/lua/orgmode/agenda/init.lua b/lua/orgmode/agenda/init.lua index 3f3d470b3..ab1d8cb23 100644 --- a/lua/orgmode/agenda/init.lua +++ b/lua/orgmode/agenda/init.lua @@ -156,7 +156,8 @@ function Agenda:_build_custom_commands() 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 = 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].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] diff --git a/lua/orgmode/agenda/types/todo.lua b/lua/orgmode/agenda/types/todo.lua index a613ec8c4..4e94aa9aa 100644 --- a/lua/orgmode/agenda/types/todo.lua +++ b/lua/orgmode/agenda/types/todo.lua @@ -108,8 +108,14 @@ 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 @@ -141,9 +147,25 @@ function OrgAgendaTodosType:render(bufnr) 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 + for _, l in ipairs(self.view.lines) do + if l.headline and l.headline:id_get_or_create() == agenda_line.headline:id_get_or_create() then + return vim.fn.cursor({ l.line_nr, 0 }) + end + end +end + ---@private ---@param headline OrgHeadline ---@param metadata table diff --git a/tests/plenary/agenda/formatter_spec.lua b/tests/plenary/agenda/formatter_spec.lua index 49688fd92..b657297d9 100644 --- a/tests/plenary/agenda/formatter_spec.lua +++ b/tests/plenary/agenda/formatter_spec.lua @@ -18,7 +18,7 @@ describe('Agenda formatter', function() 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) @@ -86,16 +86,16 @@ describe('Agenda formatter', function() 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" + 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) From 1644d2f50a74593ae2257d197892f782e6e064b4 Mon Sep 17 00:00:00 2001 From: Swapnil Mahajan Date: Fri, 3 Apr 2026 12:56:15 +0530 Subject: [PATCH 8/8] fix(agenda): avoid creating IDs during render in todo view --- lua/orgmode/agenda/types/todo.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lua/orgmode/agenda/types/todo.lua b/lua/orgmode/agenda/types/todo.lua index 4e94aa9aa..71827df62 100644 --- a/lua/orgmode/agenda/types/todo.lua +++ b/lua/orgmode/agenda/types/todo.lua @@ -159,8 +159,12 @@ function OrgAgendaTodosType:_jump_to_item(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:id_get_or_create() == agenda_line.headline:id_get_or_create() then + 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