Yet another to-do plugin for Neovim, kept to one job: tick, untick or mark a task in progress, and see a small done/total summary. It extends your markdown workflow without getting in its way, touching only task lists, so links draw and headings fold only where there are tasks.
Checkboxes for the task lists in your markdown notes. - [ ] draws as a box,
- [x] as a ticked box, and one key toggles the task under the cursor.
Everything else in the buffer stays as you wrote it, and there are no default
keymaps. No treesitter or conceallevel either, once links and compact are
both off. If you already render whole documents with
render-markdown.nvim
or markview.nvim, they draw
checkboxes too and you do not need this. tickbox is for people who want the
boxes and nothing else.
- Boxes for
[ ],[/],[x]and[-]: to do, in progress, done, cancelled. - Toggle or cycle the task under the cursor, or every task in a selection.
.repeats. <CR>,oandOcan start the next task for you.done/totalcounts on parent tasks and headings.- Parents tick themselves once every child is done.
- Headings with tasks fold, with native
za,zMandzR. - The box sits one space from the task's text, so lines indented under a task line up with it.
- Links on task lines draw as just their label, and
gxstill opens them. - No default keymaps. No treesitter or
concealleveloncelinksandcompactare both off.
To turn on everything that is off by default:
opts = { progress = true, auto_check_parent = true, fold = true }- Neovim 0.10 or later. Below that,
setupraises an error naming the minimum version. - A Nerd Font for the default icons. Without one, use Unicode icons instead:
opts = { icons = { unchecked = "☐", in_progress = "◐", checked = "☑", cancelled = "☒" } }With lazy.nvim:
{
"khiet/tickbox.nvim",
ft = "markdown",
opts = {},
keys = {
{ "<leader>t", "<Plug>(tickbox-toggle)", mode = { "n", "x" }, ft = "markdown", desc = "Toggle task" },
{ "<leader>c", "<Plug>(tickbox-cycle)", mode = { "n", "x" }, ft = "markdown", desc = "Cycle task state" },
},
}Then:
- Open any markdown file and type
- [ ] buy milk. - Leave insert mode. The
[ ]draws as a box. - Press
<leader>tto tick it, or<leader>cto walk it through to do, in progress, done and cancelled.
vim.pack and other plugin managers
With vim.pack (Neovim 0.12+):
vim.pack.add({ "https://github.com/khiet/tickbox.nvim" })
vim.keymap.set({ "n", "x" }, "<leader>t", "<Plug>(tickbox-toggle)")Any other plugin manager, or packadd, works too: the plugin attaches itself on FileType, and calling setup is optional.
require("tickbox").setup(opts), or lazy.nvim's opts, with these defaults:
require("tickbox").setup({
filetypes = { "markdown" }, -- replaces the default list
icons = { -- any of them may be left out
unchecked = "",
in_progress = "",
checked = "",
cancelled = "",
},
progress = false, -- done/total on parents and headings
auto_check_parent = false, -- keep parents in step with children
fold = false, -- fold headings that have tasks
links = true, -- draw links on task lines as their label
compact = true, -- draw the box one space from the task text
})filetypes: with lazy.nvim, extra filetypes also need listing in bothftfields of the plugin spec, or the plugin stays unloaded and unmapped there.progress: every parent task and heading with at least one counted task showsdone/totalat the end of its line, inTickboxProgress. See Progress and parents.auto_check_parent: after a toggle, cycle or set, an ancestor whose counted descendants are all checked becomes[x], and a checked ancestor with a descendant that is not becomes[ ].fold: headings that have tasks fold in attached buffers. Off, the plugin leaves every fold option untouched. See Folding.links: an inline link on a task line draws as its label, inTickboxLink.links = falsedraws links raw. See Links.compact: the box draws one space from the task's text.compact = falsepads the box to the width of- [ ], so the text stays in its raw column. See Compact box.
links and compact both hide text, which sets conceallevel and concealcursor on the window and replaces the buffer's treesitter highlighter. With both false, the plugin leaves all three alone.
Each call starts from the defaults rather than the previous call's options.
| Text | State | Icon key | Highlight |
|---|---|---|---|
[ ] |
unchecked | unchecked |
TickboxUnchecked |
[/] |
in progress | in_progress |
TickboxInProgress |
[x] or [X] |
checked | checked |
TickboxChecked |
[-] |
cancelled | cancelled |
TickboxCancelled |
Toggling is a done/not-done switch: a checked task becomes [ ] and every other task becomes [x]. Cycling walks [ ], [/], [x], [-] and back to [ ]; [X] cycles as checked. Setting goes straight to the state you name and is not a toggle: a task already in that state is left alone, and [X] stays as written when set to checked.
With progress = true, every parent task and heading shows done/total for the tasks under it. Cancelled tasks are not counted. With auto_check_parent = true, ticking the last child ticks the parent, and unticking a child unticks it again, in the same undo step. Nesting follows indentation: a task's descendants are the task lines below it with greater indentation, and a heading holds every task up to the next heading of the same or a higher level. Full rules: :h tickbox-progress.
With fold = true, headings that have tasks under them fold with the native za, zc, zM and zR. It sets foldmethod, foldexpr, foldtext and foldlevel on attached buffers, and folds start open. A closed heading keeps its own text and highlighting and, with progress, its count; the rest of the line takes the fold item of fillchars. Full rules: :h tickbox-fold.
compact is on by default. - [x] buy milk draws as the box, one space and buy milk: the rest of - [x] is hidden, so lines indented under a task line up with its text. The text does not change. With compact = false the box is padded to the width of - [x] instead, and the text stays in its raw column.
The padded box also draws wherever Neovim would show the hidden text: on every line of a visual selection, in a window at conceallevel 0 or 1, and in a buffer without a treesitter highlighter, such as markdown on Neovim 0.10 and 0.11 unless you start one. Three costs come with hiding text:
- Neovim wraps on the raw width, so a task line wraps 4 cells early.
- The text shifts 4 cells right while you type on a line or select it.
- Horizontal motions still cross the hidden prefix, so
lappears to pause there.
Full rules: :h tickbox-compact.
links is on by default. - [ ] review [ABC-123](https://example.com/ABC-123) today draws as review ABC-123 today, with the label underlined. The text does not change, and gx opens the URL as usual. No other line changes appearance: plain bullets, prose, headings and fenced lines stay raw even when they hold links.
The line being typed shows the raw link, which is how you edit a URL. Like compact, it sets conceallevel and concealcursor on the window and replaces the buffer's treesitter highlighter with one that conceals nothing else, so it needs a treesitter highlighter on the buffer; without one, links stay raw. Full rules: :h tickbox-links.
| Command | Effect |
|---|---|
:Tickbox toggle |
Toggle the cursor line, or every task in a range such as :'<,'>Tickbox toggle |
:Tickbox cycle |
Cycle the cursor line, or every task in a range such as :'<,'>Tickbox cycle |
:Tickbox set <state> |
Set the cursor line, or every task in a range such as :'<,'>Tickbox set cancelled, to unchecked, in_progress, checked or cancelled |
:Tickbox enable |
Render the current buffer again |
:Tickbox disable |
Show the raw markdown of the current buffer, until :Tickbox enable or the buffer is wiped |
Toggling, cycling and setting keep working while rendering is disabled.
The plugin ships no default keymap. <Plug>(tickbox-toggle), <Plug>(tickbox-cycle), <Plug>(tickbox-set-unchecked), <Plug>(tickbox-set-in-progress), <Plug>(tickbox-set-checked) and <Plug>(tickbox-set-cancelled) work in normal mode, where . repeats them, and in visual mode, where they act on every selected task as one undo step:
vim.keymap.set({ "n", "x" }, "<leader>t", "<Plug>(tickbox-toggle)")
vim.keymap.set({ "n", "x" }, "<leader>c", "<Plug>(tickbox-cycle)")
vim.keymap.set({ "n", "x" }, "<leader>p", "<Plug>(tickbox-set-in-progress)")Three more <Plug> mappings, also unbound, start a new unchecked task with the indentation and marker of the task line under the cursor. Off a task line they act as plain keys:
| Mapping | Mode | On a task line |
|---|---|---|
<Plug>(tickbox-newline) |
insert | Like <CR>; on an empty task it clears the line instead, ending the list |
<Plug>(tickbox-open-below) |
normal | Like o |
<Plug>(tickbox-open-above) |
normal | Like O |
The new task is [ ] whatever the state of the one under the cursor. Ordered tasks such as 1. [ ] are not continued. u undoes the new task together with the text typed after it, but . does not repeat the task prefix. The new line ignores formatoptions, so a plugin that continues lists itself, like vim-markdown, does not add a second marker. Bind them per buffer, since <CR>, o and O are too common to map everywhere:
vim.api.nvim_create_autocmd("FileType", {
pattern = "markdown",
callback = function(args)
vim.keymap.set("i", "<CR>", "<Plug>(tickbox-newline)", { buffer = args.buf })
vim.keymap.set("n", "o", "<Plug>(tickbox-open-below)", { buffer = args.buf })
vim.keymap.set("n", "O", "<Plug>(tickbox-open-above)", { buffer = args.buf })
end,
})If your completion plugin confirms with <CR>, see Recipes before binding it.
| Function | Effect |
|---|---|
setup(opts?) |
See Configuration |
toggle() |
Toggle the cursor line. Returns true if it edited the line, false on a non-task line, a fenced line, in a filetype that is not configured or in a nomodifiable buffer |
toggle_range(first, last) |
Toggle every task on these 1-indexed, inclusive lines as one undo step. Returns true if any line changed |
cycle() |
Cycle the cursor line. Returns what toggle() would |
cycle_range(first, last) |
Cycle every task on these lines as one undo step. Returns what toggle_range would |
set(state) |
Set the cursor line to "unchecked", "in_progress", "checked" or "cancelled". Returns true if it edited the line, false if the task is already in that state or in the cases where toggle() returns false. Raises an error on an unknown state |
set_range(state, first, last) |
Set every task on these lines as one undo step. Tasks already in the state are left alone. Returns true if any line changed. Raises an error on an unknown state |
enable(buf?) |
Clear disable. A filetype that is not configured still does not render |
disable(buf?) |
Stop rendering the buffer |
is_enabled(buf?) |
Whether the buffer renders: its filetype is configured and it is not disabled |
buf defaults to the current buffer. The same reference lives in :help tickbox.
| Group | Default link |
|---|---|
TickboxUnchecked |
Comment |
TickboxInProgress |
DiagnosticWarn |
TickboxChecked |
DiagnosticOk |
TickboxCancelled |
Comment |
TickboxCheckedText |
None |
TickboxProgress |
Comment |
TickboxLink |
Underlined |
All are defined with default = true, so a colorscheme or your own definition wins.
TickboxCheckedText styles the text of checked tasks, after the box. It has no attributes until you define it, so checked lines look unchanged by default. To strike done tasks through and dim them like comments:
local comment = vim.api.nvim_get_hl(0, { name = "Comment", link = false })
vim.api.nvim_set_hl(0, "TickboxCheckedText", vim.tbl_extend("force", comment, { strikethrough = true }))A link overrides every other attribute, so { strikethrough = true, link = "Comment" } would not strike anything through.
A task is a line with optional indentation, a list marker (-, *, +, or digits followed by . or )), spaces, [ ], [/], [x], [X] or [-], and then at least one space. So - [ ]x and a line ending in - [ ] stay raw. Lines inside ``` or ~~~ fences are left alone, and in insert and replace mode the cursor line shows its raw text, so the overlay never covers what you are editing. Full rules, including how fences are matched: :h tickbox-tasks.
A buffer-local <CR> hides a completion plugin's global one. If yours confirms with <CR>, check its menu first. For nvim-cmp, use this in place of the <CR> line in the Continuing a list callback:
vim.keymap.set("i", "<CR>", function()
if require("cmp").visible() then
return "<Cmd>lua require('cmp').confirm()<CR>"
end
return "<Plug>(tickbox-newline)"
end, { buffer = args.buf, expr = true, remap = true })toggle() returns false when it did nothing, so a mapping can do something else on other lines. This maps <leader><leader> to toggle on a task line and otherwise switch to the alternate buffer with <C-^>, if there is one. Calling toggle() directly means . does not repeat it:
vim.api.nvim_create_autocmd("FileType", {
pattern = "markdown",
callback = function(args)
vim.keymap.set("n", "<leader><leader>", function()
if not require("tickbox").toggle() and vim.fn.bufnr("#") ~= -1 then
vim.cmd.normal({ vim.keycode("<C-^>"), bang = true })
end
end, { buffer = args.buf })
end,
})make test runs the specs headless with plenary.nvim. It looks for plenary in ~/.local/share/nvim/lazy/plenary.nvim, where lazy.nvim installs it. Otherwise clone it and set PLENARY_DIR:
git clone https://github.com/nvim-lua/plenary.nvim /path/to/plenary.nvim
make test PLENARY_DIR=/path/to/plenary.nvimThe demo at the top is recorded with vhs from assets/demo.tape; run vhs assets/demo.tape after a visible change.
