diff --git a/CHANGELOG.md b/CHANGELOG.md index 92d73bf8..43798a39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added support for obsidian style `%%` comment. - Added `opts.daily_notes.workdays_only` option which, when false, adds support for weekend daily notes. - Added configuration option `completion.match_case`, allowing removal of duplicates when note case does not match search string case. Defaults to true for non-breaking behavior. +- Added support for orgmode style cycling heading state mapping, `` and `` ### Changed @@ -308,7 +309,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 There's a lot of new features and improvements here that I'm really excited about 🥳 They've improved my workflow a ton and I hope they do for you too. To highlight the 3 biggest additions: 1. 🔗 Full support for header anchor links and block links! That means both for following links and completion of links. Various forms of anchor/block links are support. Here are a few examples: - - Typical Obsidian-style wiki links, e.g. `[[My note#Heading 1]]`, `[[My note#Heading 1#Sub heading]]`, `[[My note#^block-123]]`. - Wiki links with a label, e.g. `[[my-note#heading-1|Heading 1 in My Note]]`. - Markdown links, e.g. `[Heading 1 in My Note](my-note.md#heading-1)`. diff --git a/lua/obsidian/api.lua b/lua/obsidian/api.lua index 0febd974..e884db96 100644 --- a/lua/obsidian/api.lua +++ b/lua/obsidian/api.lua @@ -1,7 +1,7 @@ local M = {} local log = require "obsidian.log" local util = require "obsidian.util" -local iter, string, table = vim.iter, string, table +local ts, iter, string, table = vim.treesitter, vim.iter, string, table ---builtin functions that are impure, interacts with editor state, like vim.api @@ -497,4 +497,209 @@ M.get_icon = function(path) return nil end +--- Adapted from `nvim-orgmode/orgmode` +--- Cycle all headings in file between "Show All", "Contents" and "Overview" +--- +M.cycle_global = function() + local mode = Obsidian.cycle_mode or "Show All" + if not vim.wo.foldenable or mode == "Show All" then + mode = "Overview" + vim.cmd "norm! zMzX" + elseif mode == "Contents" then + mode = "Show All" + vim.cmd [[silent! norm! zR]] + elseif mode == "Overview" then + mode = "Contents" + vim.wo.foldlevel = 1 + vim.cmd [[silent! norm! zx]] + end + vim.api.nvim_echo({ { "Obsidian: " .. mode } }, false, {}) + Obsidian.cycle_mode = mode +end +-- +-- ---@param bufnr integer +-- ---@param cursor integer[] +-- ---@return TSNode? +-- local function closest_section_node(bufnr, cursor) +-- local parser = ts.get_parser(bufnr, "markdown", {}) +-- assert(parser) +-- local cursor_range = { cursor[1] - 1, cursor[2], cursor[1] - 1, cursor[2] + 1 } +-- local node = parser:named_node_for_range(cursor_range) +-- +-- if not node then +-- return nil +-- end +-- +-- if node:type() == "section" then +-- return node +-- end +-- +-- while node and node:type() ~= "section" do +-- node = node:parent() +-- end +-- +-- return node +-- end + +-- ---@param buf number +-- ---@param pos { [1]: number, [2]: number } +-- ---@return TSNode? +-- local function closest_section_node(buf, pos) +-- local parser = vim.treesitter.get_parser(buf, "markdown") +-- assert(parser) +-- local tree = parser:parse()[1] +-- local root = tree:root() +-- local node = root:named_descendant_for_range(pos[1] - 1, pos[2], pos[1] - 1, pos[2]) +-- +-- while node do +-- if node:type() == "section" then +-- local first = node:named_child(0) +-- if first and first:type() == "atx_heading" then +-- return node +-- end +-- end +-- node = node:parent() +-- end +-- +-- return nil +-- end +---@param buf number +---@param pos { [1]: number, [2]: number } +---@return TSNode? +local function closest_section_node(buf, pos) + local parser = ts.get_parser(buf, "markdown") + assert(parser) + local tree = parser:parse()[1] + local root = tree:root() + local node = root:named_descendant_for_range(pos[1] - 1, pos[2], pos[1] - 1, pos[2]) + + while node do + if node:type() == "section" then + -- We only return a section if its first child is a heading + local first = node:named_child(0) + if first and first:type():match "_heading$" then -- supports 'atx_heading' and 'setext_heading' + print(node:type()) + return node + end + end + node = node:parent() + end + + return nil +end + +---@param node TSNode +---@return boolean +local function has_child_headlines(node) + return vim.iter(node:iter_children()):any(function(child) + return child:type() == "atx_heading" + end) +end + +---@param node TSNode +---@return TSNode[]? +local function get_child_headlines(node) + local ret = {} + for child in node:iter_children() do + if child:type() == "section" then + ret[#ret + 1] = child + end + end + return ret +end + +---@return boolean +local function is_one_line(node) + local start_row, _, end_row, end_col = node:parent():range() + -- One line sections have end range on the next line with 0 column + -- Example: If headline is on line 5, range will be (5, 1, 6, 0) + return start_row == end_row or (start_row + 1 == end_row and end_col == 0) +end +-- +-- ---@param node TSNode +-- ---@return boolean +-- local function can_section_expand(node) +-- return not is_one_line(node) or has_child_headlines(node) +-- end +local function can_section_expand(node) + local first = node:named_child(0) + if not first or first:type() ~= "atx_heading" then + return false + end + return not is_one_line(node) or has_child_headlines(node) +end + +--- Cycle heading state under cursor +M.cycle = function() + local current_buffer = vim.api.nvim_get_current_buf() + local cursor_position = vim.api.nvim_win_get_cursor(0) + local current_line = vim.fn.line "." + + -- Ensure fold system is active + if not vim.wo.foldenable then + vim.wo.foldenable = true + vim.cmd [[silent! norm! zx]] -- Refresh folds + end + + -- Check current fold state + local current_fold_level = vim.fn.foldlevel(current_line) + if current_fold_level == 0 then + return + end + + -- Handle closed folds first + local is_fold_closed = vim.fn.foldclosed(current_line) ~= -1 + if is_fold_closed then + return vim.cmd [[silent! norm! zo]] -- Open closed fold + end + + -- Find Markdown section structure + local current_section_node = closest_section_node(current_buffer, cursor_position) + if not current_section_node then + return + end + + -- Ignore non-expandable sections + if not can_section_expand(current_section_node) then + return + end + + -- Fold state management + local child_sections = get_child_headlines(current_section_node) + local should_close_parent = #child_sections == 0 + + if not should_close_parent then + local has_nested_structure = false + + -- Process child fold states + for _, child_node in ipairs(child_sections or {}) do + if can_section_expand(child_node) then + has_nested_structure = true + local child_start_line = child_node:start() + 1 + + -- Close open child folds first + if vim.fn.foldclosed(child_start_line) == -1 then + vim.cmd(string.format("silent! keepjumps norm! %dggzc", child_start_line)) + should_close_parent = true + end + end + end + + -- Return to original cursor position + vim.cmd(string.format("silent! keepjumps norm! %dgg", current_line)) + + -- Close parent if no actual nesting exists + if not should_close_parent and not has_nested_structure then + should_close_parent = true + end + end + + -- Execute final fold action + if should_close_parent then + vim.cmd [[silent! norm! zc]] -- Close parent fold + else + vim.cmd [[silent! norm! zczO]] -- Force fold refresh + end +end + return M diff --git a/lua/obsidian/init.lua b/lua/obsidian/init.lua index 5f6d9b5e..caf5e1f1 100644 --- a/lua/obsidian/init.lua +++ b/lua/obsidian/init.lua @@ -152,6 +152,16 @@ obsidian.setup = function(opts) desc = "Obsidian Smart Action", }) + vim.keymap.set("n", "", require("obsidian.api").cycle, { + buffer = true, + desc = "Obsidian Cycle", + }) + + vim.keymap.set("n", "", require("obsidian.api").cycle_global, { + buffer = true, + desc = "Obsidian Global Cyble", + }) + -- Inject completion sources, providers to their plugin configurations if opts.completion.nvim_cmp then require("obsidian.completion.plugin_initializers.nvim_cmp").inject_sources(opts) diff --git a/lua/obsidian/util.lua b/lua/obsidian/util.lua index 12590f3a..5929bf6d 100644 --- a/lua/obsidian/util.lua +++ b/lua/obsidian/util.lua @@ -514,6 +514,7 @@ end ------------------------------------ -- Miscellaneous helper functions -- ------------------------------------ + ---@param anchor obsidian.note.HeaderAnchor ---@return string util.format_anchor_label = function(anchor) @@ -678,7 +679,7 @@ util.contains_invalid_characters = function(fname) return string.find(fname, "[" .. invalid_chars .. "]") ~= nil end ----Check if a string is NaN +--- Check if a string is NaN --- ---@param v any ---@return boolean