skip to content
Adam Coates
Table of Contents

Gilles Castel’s ✝ blog posts have aged like a fine-wine. His note-making system using Neovim teamed up with inkscape for figure generation has been nothing short of inspirational for many. In his blog’s he showcases the ways in which he is able to quickly create notes in his maths lectures. Primarily he showed the use of snippets in Neovim to be able to write math Latex incredibly fast and a snippet and hotkey system he created to generate figures in inkscape. The end result is that his notes become beautifully crafted pdf’s.

In this blog, I will go into detail of how I have recreated some parts of Giles Castel’s note-making system. I split this blog into two parts. The first part explains how I use snippets in my note-making system and the second part explains how I create figures in inkscape that are automatically embedded in Neovim.

Wayland vs. X11

In Gilles Castel’s set-up he was using X11 display server and there are advantages to using X11 with this set-up that make using wayland display server a little more complicated than it needs to be. Nonetheless, it is still possible to achieve a similar setup with wayland. In fact, Gilles, inspired many others to achieve a similar setup, that wayland versions of it exist. This blog details how a similar setup can be achieved in wayland, which is also what inspired me to do the same.

Snippets

For the Neovim snippets, however, it doesn’t matter which display protocol that you use. Instead, what matters is the snippet engine/ plugin that you are using in Neovim. I made a post a while a go about my Neovim setup, however since that post was made 2 years ago, a lot of my Neovim configuration has evolved! Largely, the snippet plugin and completion plugin that I used to use remain unchanged but that there are some slight differences.

First and foremost, I don’t tend to write notes in Latex like Giles Castel, nor do I write notes in Typst like what this blog uses 1 2. Instead, my note-making system is largely based around markdown notes. I prefer this method over creating pdf’s as my notes tend to evolve over time. Whereas, I see a pdf as a final product that is unlikely to ever be changed, markdown on the other hand is practically plain text with some formatting.

Snippets are controlled in Neovim using plugins. Here is my configuration that shows how snippets as well as completion is handled:

return {
"hrsh7th/nvim-cmp",
event = "InsertEnter",
dependencies = {
"hrsh7th/cmp-buffer", -- source for text in buffer
"hrsh7th/cmp-path", -- source for file system paths
"L3MON4D3/LuaSnip", -- snippet engine
"saadparwaiz1/cmp_luasnip", -- for autocompletion
"rafamadriz/friendly-snippets", -- useful snippets
"onsails/lspkind.nvim", -- vs-code like pictograms
},
config = function()
local cmp = require("cmp")
local luasnip = require("luasnip")
local lspkind = require("lspkind")
local check_back_space = function()
local col = vim.fn.col(".") - 1
if col == 0 or vim.fn.getline("."):sub(col, col):match("%s") then
return true
else
return false
end
end
require("luasnip.loaders.from_vscode").lazy_load({})
-- require("luasnip.loaders.from_vscode").lazy_load({ paths = "./my_snippets" })
-- require("luasnip.loaders.from_lua").lazy_load({ paths = "./lua_snippets" })
require("luasnip").config.setup({ store_selection_keys = "<C-s>" })
vim.api.nvim_set_keymap(
"i",
"<C-u>",
'<cmd>lua require("luasnip.extras.select_choice")()<CR>',
{ noremap = true }
)
luasnip.filetype_extend("quarto", { "markdown" })
cmp.setup({
completion = {
completeopt = "menu,menuone,preview,noselect",
},
snippet = { -- configure how nvim-cmp interacts with snippet engine
expand = function(args)
luasnip.lsp_expand(args.body)
end,
},
mapping = cmp.mapping.preset.insert({
["<C-p>"] = cmp.mapping.select_prev_item(), -- previous suggestion
["<C-n>"] = cmp.mapping.select_next_item(), -- next suggestion
["<C-b>"] = cmp.mapping.scroll_docs(-4),
["<C-f>"] = cmp.mapping.scroll_docs(4),
["<C-Space>"] = cmp.mapping.complete(), -- show completion suggestions
["<C-e>"] = cmp.mapping.abort(), -- close completion window
["<CR>"] = cmp.mapping.confirm({ select = false }),
["<Tab>"] = cmp.mapping(function(fallback)
if cmp.visible() then
cmp.confirm({ select = true })
elseif luasnip.jumpable(1) then
luasnip.jump(1)
elseif check_back_space() then
fallback()
else
cmp.complete()
end
end, { "i", "s" }),
["<S-Tab>"] = cmp.mapping(function()
luasnip.jump(-1)
end, { "i", "s" }),
}),
vim.keymap.set({ "i", "s" }, "<C-s>", function()
if luasnip.expandable() then
luasnip.expand({})
end
end),
-- sources for autocompletion
sources = cmp.config.sources({
{ name = "luasnip" }, -- snippets
{ name = "nvim_lsp" },
{ name = "buffer" }, -- text within current buffer
{ name = "path" }, -- file system paths
}),
formatting = {
format = lspkind.cmp_format({
maxwidth = 50,
ellipsis_char = "...",
}),
},
})
end,
}

You’ll notice that this configuration uses the Neovim plugin nvim-cmp for the completion engine. The completion engine allows to add different sources that can be used for completion. One of the main sources of completion is lsp (not mentioned in this blog).

Another source that is useful in note-making is snippets. In this configuration, I added 2 sources of snippets to the Luasnip engine.

  1. Friendly snippets
  2. VS code snippets

Example snippets

Snippets can be written in json. For example, friendly snippets are written in json. There is a table-making snippet in friendly snippets like so:

"Insert 5x1 table": {
"prefix": "5x1table",
"body": [
"| ${1:Column1} |",
"| ------------- |",
"| ${2:Item1} |",
"| ${3:Item2} |",
"| ${4:Item3} |",
"| ${5:Item4} |",
"${0}"
],
"description": "Insert table with 5 rows and 1 column. First row is heading."
},
  • Prefix: represents how to trigger the snippet in neovim literally by typing out:
  • Body: represents what the trigger completes to:
    • Each ${NUM represents where the cursor jumps to when Tab is pressed on the keyboard

Here is an example of a snippet to create a table:

Figures

I wanted a similar way to create figures as Giles Castel did. For this, I similarly use inkscape as he does. I do most of my writing inside of neovim and so there are 2 parts to how I create figures inkscape.

  1. Quickly inserting the necessary markdown text for a figure in neovim using a trigger

  2. Ability to apply styling to inkscape figures using shortcuts

The trigger

The trigger in neovim is used primarily to insert the syntax needed for inkscape i.e. ![Figure name](/path/to/figure)

To do this, I have a Neovim keybind set up that calls a bash script. I first type out the filename that I want my figure to have. Then I press Ctrl+f. This trigger surrounds the text on a new line in neovim with the correct markdown syntax.

For example, in neovim I type the following:

Figure 1

I then press Ctrl+f which automatically formats the text like so:

`![Figure 1](./path/to/svg/file)`

And then inkscape opens automatically and then I can start to create the SVG

The stylinator

The idea behind “the stylinator” is that it is essentially a way to add shortcuts to styles that are useful in Inkscape. In Gilles’ original post, he details ways to use key chords, where multiple keys are pressed simultaneously, to quickly apply different styles. His original approach uses X11 to intercept these key combinations and apply the corresponding style.

On Wayland, this is more complicated because applications cannot generally intercept global keyboard input in the same way. Since I use Wayland, specifically Hyprland, I decided to use an application launcher as the interface instead. I can trigger the stylinator from Hyprland and then select the style I want using either Rofi/Walker or Quickshell.

The actual work is done by a small Python script:

#!/usr/bin/env python3
# Usage:
# 1. Show menu: walker --dmenu < <(stylinator.py --menu) | stylinator.py | wl-copy -t "image/x-inkscape-svg"
# 2. Direct input: walker --dmenu | stylinator.py | wl-copy -t "image/x-inkscape-svg"
import sys
from typing import Any
STYLE_OPTIONS = """s - Stroke (border)
a - Arrow end
x - Arrows both ends
d - Dashed line
e - Dotted line
g - Thick/bold stroke
h - Very thick stroke
f - Semi-transparent fill (12%)
b - Solid black fill
w - Solid white fill
---
sa - Stroke + arrow
ag - Bold arrow
fs - Fill + stroke
sg - Bold stroke
dg - Bold dashed line"""
def gen_style(combination):
"""This creates the style depending on the combination of keys."""
# Stolen from TikZ
mm = 3.78 # pixels
w = 0.4 * mm
thick_width = 0.8 * mm
very_thick_width = 1.2 * mm
style: dict[str, Any] = {"stroke-opacity": 1}
if {"s", "a", "d", "g", "h", "x", "e"} & combination:
style["stroke"] = "black"
style["stroke-width"] = w
style["marker-end"] = "none"
style["marker-start"] = "none"
style["stroke-dasharray"] = "none"
else:
style["stroke"] = "none"
if "g" in combination:
w = thick_width
style["stroke-width"] = w
if "h" in combination:
w = very_thick_width
style["stroke-width"] = w
if "a" in combination:
style["marker-end"] = "url(#ArrowWideAgain)"
if "x" in combination:
style["marker-start"] = "url(#ArrowWideAgain)"
style["marker-end"] = "url(#ArrowWideAgain)"
if "d" in combination:
style["stroke-dasharray"] = f"{w},{2 * mm}"
if "e" in combination:
style["stroke-dasharray"] = f"{3 * mm},{3 * mm}"
if "f" in combination:
style["fill"] = "black"
style["fill-opacity"] = 0.12
if "b" in combination:
style["fill"] = "black"
style["fill-opacity"] = 1
if "w" in combination:
style["fill"] = "white"
style["fill-opacity"] = 1
if {"f", "b", "w"} & combination:
style["marker-end"] = "none"
style["marker-start"] = "none"
if not {"f", "b", "w"} & combination:
style["fill"] = "none"
style["fill-opacity"] = 1
if style["fill"] == "none" and style["stroke"] == "none":
return
# Start creation of the svg.
# Later on, we'll write this svg to the clipboard, and send Ctrl+Shift+V to
# Inkscape, to paste this style.
svg = """
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg>
"""
# If a marker is applied, add its definition to the clipboard
# Arrow styles stolen from tikz
if ("marker-end" in style and style["marker-end"] != "none") or (
"marker-start" in style and style["marker-start"] != "none"
):
svg += f"""
<defs id="marker-defs">
<marker
style="overflow:visible"
id="ArrowWideAgain"
refX="0"
refY="0"
orient="auto-start-reverse"
markerWidth="1"
markerHeight="1"
viewBox="0 0 1 1"
preserveAspectRatio="xMidYMid">
<path
style="fill:none;stroke:context-stroke;stroke-width:1;stroke-linecap:butt"
d="M 3,-3 0,0 3,3"
transform="rotate(180,0.125,0)"
sodipodi:nodetypes="ccc"
id="path4" />
</marker>
<marker
id="marker-arrow-{w}"
orient="auto-start-reverse"
refY="0" refX="0"
markerHeight="1.690" markerWidth="0.911">
<g transform="scale({(2.40 * w + 3.87) / (4.5 * w)})">
<path
d="M -1.55415,2.0722 C -1.42464,1.29512 0,0.1295 0.38852,0 0,-0.1295 -1.42464,-1.29512 -1.55415,-2.0722"
style="fill:none;stroke:#000000;stroke-width:{0.6};stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
inkscape:connector-curvature="0" />
</g>
</marker>
</defs>
"""
style_string = ";".join(
"{}: {}".format(key, value)
for key, value in sorted(style.items(), key=lambda x: x[0])
)
svg += f'<inkscape:clipboard style="{style_string}" /></svg>'
return svg
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--menu":
# Print menu options
print(STYLE_OPTIONS)
else:
# Process input
user_input = input().strip()
# Extract just the key combination (remove description if present)
# e.g., "sa - Stroke + arrow" -> "sa"
combination_str = user_input.split()[0] if user_input else ""
# Skip separator lines
if combination_str == "---":
sys.exit(0)
result = gen_style(combination=set(combination_str))
if result:
print(result)

The script has two main jobs. First, it provides a list of styles that can be selected from the launcher. Second, it takes the selected style and converts it into an SVG containing the appropriate Inkscape style information.

The available styles are defined near the top of the script:

STYLE_OPTIONS = """s - Stroke (border)
a - Arrow end
x - Arrows both ends
d - Dashed line
e - Dotted line
g - Thick/bold stroke
h - Very thick stroke
f - Semi-transparent fill (12%)
b - Solid black fill
w - Solid white fill
---
sa - Stroke + arrow
ag - Bold arrow
fs - Fill + stroke
sg - Bold stroke
dg - Bold dashed line"""

The first part of the list contains the basic styles. For example, s means a normal stroke, a adds an arrow, d makes the line dashed, and f creates a semi-transparent fill. These can also be combined, so sa means a stroke with an arrow and dg means a bold dashed line.

This is somewhat similar to Gilles’ original idea of using combinations of keys. I have kept the combination system in the Python script even though I am not using physical key chords. The launcher simply gives me a more convenient way of selecting those combinations.

The actual style is generated by gen_style(). It starts with some basic dimensions:

mm = 3.78
w = 0.4 * mm
thick_width = 0.8 * mm
very_thick_width = 1.2 * mm

These values are based on the dimensions used by TikZ. The function then builds a Python dictionary containing the SVG/Inkscape style properties. For example, if the selected combination contains g, the stroke width is increased. If it contains d, a dashed stroke is created, while e creates a dotted stroke.

The function also handles arrows. Selecting a adds an arrow to the end of the line, while x adds arrows to both ends. The necessary SVG marker definitions are then included in the generated SVG.

Fills work in a similar way. f produces a black fill with 12% opacity, while b and w produce solid black and white fills respectively. If no fill option is selected, the fill is set to none.

The interesting part is what happens at the end. Rather than trying to communicate directly with Inkscape, the script constructs an SVG containing an inkscape:clipboard element:

svg += f’<inkscape:clipboard style=“{style_string}” />’

The resulting SVG is printed to standard output. My launcher pipeline then takes this output and puts it onto the Wayland clipboard with the MIME type image/x-inkscape-svg:

Terminal window
walker --dmenu < <(stylinator.py --menu) | \
stylinator.py | \
wl-copy -t "image/x-inkscape-svg"

This means that the Python script itself does not need to know anything about the launcher or the clipboard. It simply receives a style selection from standard input and outputs the SVG. This makes the individual components quite modular: Walker provides the interface, Python generates the style, and wl-copy handles the clipboard.

Once the SVG is on the clipboard, it can be pasted into Inkscape. Inkscape recognises the special clipboard information and applies the style to the selected object. This is what allows the whole thing to function as a style picker without having to interact with Inkscape’s fill and stroke interface manually.

I therefore ended up with something slightly different from Gilles’ original stylinator. The underlying idea is the same — have a collection of frequently used styles that can be applied almost instantly — but the interface is adapted to Wayland. Instead of relying on global keyboard chords, Hyprland launches a menu, the style is selected through Walker/Rofi or Quickshell, and the Python script generates the appropriate Inkscape SVG.

This also means that adding a new style is relatively simple. I can add another entry to STYLE_OPTIONS and then add the corresponding logic to gen_style(). The launcher automatically makes the new option available.

The complete workflow is therefore roughly:

Hyprland → Walker/Rofi/Quickshell → Python stylinator → SVG → Wayland clipboard → Inkscape

This gives me a quick way of applying consistent styles while keeping the actual figure creation in Inkscape.

Conclusion

Gilles Castel’s note-taking system is interesting to me not simply because of the tools he used, but because of the philosophy behind them. The goal was to remove as much friction as possible from the process of taking notes. Snippets make it possible to write repetitive structures quickly, while the figure workflow makes it possible to create and insert figures without constantly breaking away from the editor.

I have ended up with something that is not quite the same as Gilles’ original setup. I use Markdown rather than LaTeX, Neovim remains the centre of my writing workflow, and because I use Wayland rather than X11 I had to find different ways of achieving some of the shortcuts that made his system so effective. The result is a collection of small tools and scripts that fit the way I actually work rather than attempting to reproduce his setup exactly.

In some ways, this is probably the most useful part of Gilles’ approach. The particular tools are less important than the idea of building a workflow around the things that you do repeatedly. Once a task is automated or reduced to a simple shortcut, you no longer have to think about the mechanics of doing it. You can concentrate on the thing you are actually trying to write or draw.

For me, that means Markdown and Neovim for writing, snippets for repetitive text, Inkscape for figures, and a small collection of scripts that connect the two. None of these components are particularly complicated on their own. Together, however, they make creating and maintaining notes considerably less cumbersome.

Gilles’ original system was built around his own requirements and the technology available to him. Recreating it on a modern Wayland desktop therefore inevitably means making compromises and substitutions. But the underlying idea remains surprisingly durable: build your tools around your workflow, automate the repetitive parts, and make the computer get out of the way.

Footnotes

  1. Although I have looked into using Typst for creating pdf’s and it is something I would like to explore more in the future.

  2. I would argue that Typst is a language that can be used inside of quarto instead of relying on Latex .tex for creating quarto pdf’s. Although not strictly necessary to create pdf’s, the Typst language in quarto can be used in addition to markdown text or as an extension.

Reactions

Comments

Loading comments...