▗▖   █  ▐▌█  ▄ ▗▞▀▚▖     ▗▖  ▗▖▗▞▀▜▌▄▄▄▄  
▐▌   ▀▄▄▞▘█▄▀  ▐▛▀▀▘     ▐▌  ▐▌▝▚▄▟▌█   █ 
▐▌        █ ▀▄ ▝▚▄▄▖     ▐▌  ▐▌     █   █ 
▐▙▄▄▖     █  █            ▝▚▞▘            
HomePages

Making Frankly.nvim

Inspired by a video by Coding with Sphere, I started working on simple to do plugin, embedded into Neovim. The premise was fairly simple: a buffer I can open in any project to read and edit my todo list.

My workflow with things like this, is get the minimum viable product, so the first step was already done by Coding with Sphere. Using their setup as a jumping off point, I adjusted the border and added a few autocmds to make sure that when I resize the window, the pop-up is resized and remains centered.

Resizing window

For this, I needed to move some of the action to a new function which I called refresh_window. This function also handles reassigning my keymaps and autocmds. More on that later!

local function refresh_window()
	local buf = state.buf or 0

	state.cmdid = vim.api.nvim_create_autocmd("VimResized", {
		buffer = state.buf,
		callback = function()
			vim.api.nvim_win_set_config(state.win, win_config(state.opts))
		end,
	})

	vim.api.nvim_buf_set_keymap(buf, "n", "q", "", {
		-- quit the window
	})

	vim.api.nvim_buf_set_keymap(buf, "n", "m", "", {
		-- mini the window
	})

	vim.api.nvim_buf_set_keymap(buf, "n", "<M-n>", "", {
		-- next note
	})
	vim.api.nvim_buf_set_keymap(buf, "n", "<M-p>", "", {
		-- previous note
	})
	vim.api.nvim_buf_set_keymap(buf, "n", "f", "", {
		-- fullscreen the window
	})
end

This is paired with my win_config function, which handles setting up the window configuration in a clean functio function, which handles setting up the window configuration in a clean function:

local function win_config(opts)
	local basew = vim.o.columns
	local baseh = vim.o.lines

	local width = state.sticky and 40 or math.min(basew, opts.width)
	local height = state.sticky and 10 or math.min(baseh - 5, opts.height)
	local col = state.sticky and vim.o.columns - width - 2 or  (basew - width) / 2
	local row = state.sticky and 1 or math.max(((baseh - height) / 2) - 2, 1)
	return {
		relative = "editor",
		width = width,
		height = height,
		col = col,
		row = row,
		border = opts.border,
		title = { { " Frankly ", "Normal" } },
		title_pos = "center",
		footer = { { " Next [meta-n] - Previous [meta-p] - Fullscreen [f] - Quit [q] ", "Normal" } },
		footer_pos = "center",
	}
end

It's a bit of a mouthful, but basically all we do here is return a nice table of all the things needed for the window to be drawn. This lets us spam this whenever needed to update the positioning of the window.

Parsing todos

Now that that was working, I wanted to adapt it some more. For me personally, I liked to use the dated notes in Obsidian, so my next idea was to work with dated notes. The flow would be: open the window > it shows today's note > if no today note, parse previous note and remove all except the unchecked items and their headings > save as today's note > show today's note.

I thought this would be a good time to learn some Treesitter queries and just get a better understanding of how it works.

This is a bit of a weird file as I kinda don't know what I'm doing, but it does work:

local ts = vim.treesitter
local query = ts.query

local M = {}

local function read_file(path)
	local f = io.open(path, "r")
	if not f then
		return nil
	end
	local content = f:read("*a")
	f:close()
	return content
end

function M.parse_todos(file)
	local usefile = file or ""

	local content = read_file(file)
	if not content then
		print("Failed to read file:", file)
		return
	end

	local lang = "markdown"
	local parser = ts.get_string_parser(content, lang)
	local tree = parser:parse()[1]
	local root = tree:root()

	local q = query.parse(
		lang,
		[[
    (section
      (atx_heading
        [(atx_h1_marker) (atx_h2_marker) (atx_h3_marker)] @level
        (inline) @heading)
      (list
        (list_item
          (task_list_marker_unchecked) @unchecked_marker
          (paragraph (inline) @task))))
    ]]
	)

	local results = {}
	local current_heading = nil
	local heading_level = nil
	local current_tasks = {}

	table.insert(results, "# Todo")

	for id, node in q:iter_captures(root, content, 0, -1) do
		local name = q.captures[id]
		local text = ts.get_node_text(node, content)

		if name == "level" then
			heading_level = #text -- number of '#' characters
		elseif name == "heading" then
			-- Flush previous group if needed
			if current_heading and #current_tasks > 0 then
				table.insert(results, string.rep("#", heading_level or 1) .. " " .. current_heading)

				table.insert(results, "")
				for _, task in ipairs(current_tasks) do
					table.insert(results, task)
				end
				table.insert(results, "")
			end
			-- Start new group
			current_heading = text
			current_tasks = {}
		elseif name == "task" then
			table.insert(current_tasks, "- [ ] " .. text)
			table.insert(results, "")
		end
	end

	-- Final flush after loop
	if current_heading and #current_tasks > 0 then
		table.insert(results, string.rep("#", heading_level or 1) .. " " .. current_heading)
		table.insert(results, "")
		for _, task in ipairs(current_tasks) do
			table.insert(results, task)
		end
	end

	-- clean double empty lines
	local i = 2
	while i <= #results do
		if results[i] == results[i - 1] then
			table.remove(results, i)
		else
			i = i + 1
		end
	end

	return results
end

return M

I broke this out to a new file just for ease of use, so it's easier to just pass it the file and get back the data we want to keep.

As I said above, it will strip down the file to only give us the leftover todos, so this:

# Todo

## Work

- [x] Reroute network cables

## General

- [ ] Go and pick up order
- [x] Write birthday card

will give us back:

# Todo

## General

- [ ] Go and pick up order

This lets us keep a nice record of tasks from previous days, and keep our current todo nice and clean.

Walking files

Given that I made this specifically work with multiple files, I thought it would be helpful to make it possible to walk through all of the todo files in the folder. This was fairly painless, with the exception of needed to basically reassign everything to the new file as we walk, as most of them are buffer specific keymaps and autocmds.

M.walk_files = function(dir)
	local tdir = state.target_dir
	local cwdContent = vim.split(vim.fn.glob(tdir .. "/*"), "\n", { trimempty = true })
	state.file_index = state.file_index + dir
	state.file_index = math.min(#cwdContent, state.file_index)
	state.file_index = math.max(1, state.file_index)
	local new_path = cwdContent[state.file_index]

	local buf = vim.fn.bufnr(new_path, true)

	if buf == -1 then
		buf = vim.api.nvim_create_buf(false, false)
		vim.api.nvim_buf_set_name(buf, new_path)
	end

	vim.bo[buf].swapfile = false

	state.buf = buf

	if state.win and vim.api.nvim_win_is_valid(state.win) then
		vim.api.nvim_win_set_buf(state.win, buf)
	end
	refresh_window()
end

I have a state table that stores all of the "persistent" things related to the window and buffers, which just helps me pull those all around the module and work with them, so we can just update state.buf and refresh the window.

Sticky note mode

The last thing I wanted to add, was a keymap to send this to a little window in the top right corner, to keep it there if needed. This was fairly simple, just setting state.sticky to true and using that in our refresh_window to set the size and position accordingly. All of the keymaps I ended up having are these:

local function refresh_window()
	local buf = state.buf or 0

	state.cmdid = vim.api.nvim_create_autocmd("VimResized", {
		buffer = state.buf,
		callback = function()
			vim.api.nvim_win_set_config(state.win, win_config(state.opts))
		end,
	})

	vim.api.nvim_buf_set_keymap(buf, "n", "q", "", {
		noremap = true,
		silent = true,
		callback = function()
			if vim.api.nvim_get_option_value("modified", { buf = buf }) then
				vim.notify("Unsaved changes!", vim.log.levels.WARN)
			else
				vim.api.nvim_win_close(0, true)
				state.sticky = false
				state.win = nil
				state.buf = nil
			end
		end,
	})

	vim.api.nvim_buf_set_keymap(buf, "n", "m", "", {
		noremap = true,
		silent = true,
		callback = function()
			vim.api.nvim_clear_autocmds({ buffer = state.buf })
			state.sticky = true
			vim.api.nvim_win_set_config(state.win, win_config(state.opts))
			refresh_window()
		end,
	})

	vim.api.nvim_buf_set_keymap(buf, "n", "<M-n>", "", {
		noremap = true,
		silent = true,
		callback = function()
			vim.api.nvim_clear_autocmds({ buffer = buf })
			M.walk_files(1)
		end,
	})
	vim.api.nvim_buf_set_keymap(buf, "n", "<M-p>", "", {
		noremap = true,
		silent = true,
		callback = function()
			vim.api.nvim_clear_autocmds({ buffer = buf })
			M.walk_files(-1)
		end,
	})
	vim.api.nvim_buf_set_keymap(buf, "n", "f", "", {
		noremap = true,
		silent = true,
		callback = function()
			state.sticky = false
			state.win = nil
			state.buf = nil
			local fp = vim.api.nvim_buf_get_name(0)
			vim.api.nvim_win_close(0, true)
			vim.cmd("e " .. fp)
		end,
	})
end

Download it, fork it, have fun!

Frankly.nvim

You have made it to the bottom.


▄▄▄▄  ▗▞▀▜▌█  ▄ ▗▞▀▚▖     ▄▄▄  ▄▄▄  ▄▄▄▄  ▗▞▀▚▖   ■  ▐▌   ▄ ▄▄▄▄    
█ █ █ ▝▚▄▟▌█▄▀  ▐▛▀▀▘    ▀▄▄  █   █ █ █ █ ▐▛▀▀▘▗▄▟▙▄▖▐▌   ▄ █   █   
█   █      █ ▀▄ ▝▚▄▄▖    ▄▄▄▀ ▀▄▄▄▀ █   █ ▝▚▄▄▖  ▐▌  ▐▛▀▚▖█ █   █   
           █  █                                  ▐▌  ▐▌ ▐▌█     ▗▄▖ 
                                                 ▐▌            ▐▌ ▐▌
                                                                ▝▀▜▌
                                                               ▐▙▄▞▘

Copyright (c) 2025 Luke Van. All Rights Reserved.