Skip to content

Commit fbbc95f

Browse files
committed
zettelkasten: Implement picker that wraps visual selection with link
1 parent 6a938c6 commit fbbc95f

9 files changed

Lines changed: 305 additions & 5 deletions

File tree

lua/plugins/zettelkasten.lua

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,14 @@ return {
1515
end,
1616
desc = "Open note",
1717
},
18+
{
19+
"<localleader>ln",
20+
function()
21+
require("zettelkasten.edit").surround_visual_selection_with_link_to_note()
22+
end,
23+
mode = "v",
24+
ft = { "markdown" },
25+
desc = "Surround with link to note",
26+
},
1827
},
1928
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
local nvim_utils = require("zettelkasten.utils.nvim")
2+
local path_utils = require("zettelkasten.utils.path")
3+
local pickers = require("zettelkasten.pickers")
4+
5+
local M = {}
6+
7+
---Inserts the string `s` at position.
8+
---@param position zettelkasten.utils.nvim.position.Position
9+
---@param s string
10+
local function insert_at_position(position, s)
11+
local line_pre = vim.api.nvim_buf_get_lines(0, position.row - 1, position.row, true)[1]
12+
local left = line_pre:sub(1, position.column - 1)
13+
local right = line_pre:sub(position.column)
14+
local line_post = left .. s .. right
15+
vim.api.nvim_buf_set_lines(0, position.row - 1, position.row, true, { line_post })
16+
end
17+
18+
--- Surrounds the text of a single-line visual range.
19+
--- @param visual_range VisualRange
20+
--- @param prefix string
21+
--- @param suffix string
22+
local function surround_single_line(visual_range, prefix, suffix)
23+
assert(visual_range:is_single_line())
24+
25+
local row = visual_range.from.row
26+
local line = vim.api.nvim_buf_get_lines(0, row - 1, row, true)[1]
27+
28+
local left = line:sub(1, visual_range.from.column - 1)
29+
local center = line:sub(visual_range.from.column, visual_range.to.column)
30+
local right = line:sub(visual_range.to.column + 1)
31+
32+
local surrounded = left .. prefix .. center .. suffix .. right
33+
vim.api.nvim_buf_set_lines(0, row - 1, row, true, { surrounded })
34+
end
35+
36+
--- Surrounds the text of a multi-line visual range.
37+
--- @param visual_range VisualRange
38+
--- @param prefix string
39+
--- @param suffix string
40+
local function surround_multi_line(visual_range, prefix, suffix)
41+
assert(not visual_range:is_single_line())
42+
43+
insert_at_position(visual_range.from, prefix)
44+
vim.notify(vim.inspect(visual_range.to))
45+
insert_at_position(visual_range.to:offset_column(1), suffix)
46+
end
47+
48+
--- Surrounds a range within the buffer with the `left` and `right` srings.
49+
---
50+
--- @param visual_range VisualRange
51+
--- @param left string
52+
--- @param right string
53+
function M.surround(visual_range, left, right)
54+
if visual_range:is_single_line() then
55+
surround_single_line(visual_range, left, right)
56+
else
57+
surround_multi_line(visual_range, left, right)
58+
end
59+
end
60+
61+
M.surround_visual_selection_with_link_to_note = function()
62+
nvim_utils.assert_visual_mode()
63+
64+
--- Current visual range start position.
65+
local _, row1, col1, _ = unpack(vim.fn.getpos("v"))
66+
local row2, col2 = unpack(vim.api.nvim_win_get_cursor(0))
67+
col2 = col2 + 1
68+
69+
if (row2 < row1) or (row1 == row2 and col2 < col1) then
70+
row1, row2 = row2, row1
71+
col1, col2 = col2, col1
72+
end
73+
74+
pickers.pick_note({
75+
confirm = function(picker, selected_item)
76+
picker:close()
77+
local url = path_utils.relative_to_parent_directory(selected_item.file)
78+
79+
if row1 == row2 then
80+
local line = vim.api.nvim_buf_get_lines(0, row1 - 1, row1, true)[1]
81+
local left = line:sub(1, col1 - 1)
82+
local center = line:sub(col1, col2)
83+
local right = line:sub(col2 + 1)
84+
85+
local wrapped_line = left .. "[" .. center .. "](" .. url .. ")" .. right
86+
87+
vim.api.nvim_buf_set_lines(0, row1 - 1, row1, true, { wrapped_line })
88+
else
89+
local line1 = vim.api.nvim_buf_get_lines(0, row1 - 1, row1, true)[1]
90+
local left1 = line1:sub(1, col1 - 1)
91+
local right1 = line1:sub(col1)
92+
local wrapped_line1 = left1 .. "[" .. right1
93+
vim.api.nvim_buf_set_lines(0, row1 - 1, row1, true, { wrapped_line1 })
94+
95+
local line2 = vim.api.nvim_buf_get_lines(0, row2 - 1, row2, true)[1]
96+
local left2 = line2:sub(1, col2)
97+
local right2 = line2:sub(col2 + 1)
98+
local wrapped_line2 = left2 .. "](" .. url .. ")" .. right2
99+
vim.api.nvim_buf_set_lines(0, row2 - 1, row2, true, { wrapped_line2 })
100+
end
101+
102+
nvim_utils.press_esc()
103+
end,
104+
})
105+
end
106+
107+
return M

myplugins/zettelkasten/lua/zettelkasten/pickers.lua

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
local cli = require("zettelkasten.cli")
2+
local table_utils = require("zettelkasten.utils.table")
3+
24
local snacks = require("snacks")
35

46
local M = {}
@@ -22,4 +24,24 @@ M.open_note = function()
2224
})
2325
end
2426

27+
--- Pick notes from the zettelkasten.
28+
--- @param opts table?
29+
M.pick_note = function(opts)
30+
local notes = cli.note_list()
31+
32+
local items = {}
33+
for _, note in ipairs(notes) do
34+
items[#items + 1] = {
35+
file = note.location,
36+
text = note.title,
37+
}
38+
end
39+
40+
local mutable_opts = table_utils.shallow_copy(opts or {})
41+
mutable_opts.items = items
42+
mutable_opts.format = "text" -- Display the `text` field instead of the `file`.
43+
44+
snacks.picker.pick(mutable_opts)
45+
end
46+
2547
return M
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
local M = {}
2+
3+
function M.is_visual_mode()
4+
return vim.fn.mode():match("[vV\22]")
5+
end
6+
7+
---Asserts that the current mode is visual mode or throws an error.
8+
---@param msg string?
9+
M.assert_visual_mode = function(msg)
10+
assert(M.is_visual_mode(), msg)
11+
end
12+
13+
--- Returns the parent directory of the buffer.
14+
--- @param buffer integer
15+
--- @return string
16+
M.buf_get_directory = function(buffer)
17+
local file_path = vim.api.nvim_buf_get_name(buffer)
18+
return vim.fn.fnamemodify(file_path, ":h")
19+
end
20+
21+
function M.press_esc()
22+
local keys = vim.api.nvim_replace_termcodes("<Esc>", true, false, true)
23+
vim.api.nvim_feedkeys(keys, "n", false)
24+
end
25+
26+
return M
Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
--- Represents a position in the buffer.
2-
--- @class (exact) Position
2+
--- @class (exact) zettelkasten.utils.nvim.position.Position
33
--- @field row integer A positive integer.
44
--- @field column integer A positive integer.
55
local Position = {}
66

77
--- Instantiates a new mark.
88
--- @param o { row: integer, column: integer }
9-
--- @return Position
9+
--- @return zettelkasten.utils.nvim.position.Position
1010
function Position:new(o)
1111
assert(type(o.column) == "number", "Field `column` must be a `number`")
1212
assert(o.column % 1 == 0, "Field `column` must be an integer")
@@ -15,14 +15,14 @@ function Position:new(o)
1515
assert(o.row % 1 == 0, "Field `row` must be an integer")
1616
assert(0 < o.row, "Field `row` must be positive")
1717

18-
setmetatable(o, self)
18+
setmetatable(o, { __index = self })
1919
return o
2020
end
2121

2222
--- Constructs a `Position` from a Vim `getpos` expression.
2323
---
2424
--- @param expr string An expression accepted by `vim.fn.getpos`.
25-
--- @return Position
25+
--- @return zettelkasten.utils.nvim.position.Position
2626
function Position:getpos(expr)
2727
local _, row, column, _ = unpack(vim.fn.getpos(expr))
2828
return Position:new({ row = row, column = column })
@@ -31,8 +31,18 @@ end
3131
--- Returns true if and only if this `Position` comes before the other
3232
--- `Position`.
3333
---
34-
--- @param other Position
34+
--- @param other zettelkasten.utils.nvim.position.Position
3535
--- @return boolean
3636
function Position:__lt(other)
3737
return self.row < other.row or (self.row == other.row and self.column < other.column)
3838
end
39+
40+
--- Returns a new Position with the `column` field offset by `n`.
41+
---
42+
--- @param n number Integer column offset.
43+
--- @return zettelkasten.utils.nvim.position.Position
44+
function Position:offset_column(n)
45+
return Position:new({ row = self.row, column = self.column + n })
46+
end
47+
48+
return Position
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
--- @alias VisualMode
2+
---| "v" # Character-wise Visual mode.
3+
---| "V" # Line-wise Visual mode.
4+
---| "\22" # Block-range Visual mode. "\22" is the escaped form of Ctrl-V
5+
6+
--- @class (exact) VisualRange
7+
--- @field mode VisualMode
8+
--- @field from zettelkasten.utils.nvim.position.Position
9+
--- @field to zettelkasten.utils.nvim.position.Position
10+
local VisualRange = {}
11+
12+
--- Creates a new VisualRange.
13+
--- @param mode VisualMode
14+
--- @param from zettelkasten.utils.nvim.position.Position
15+
--- @param to zettelkasten.utils.nvim.position.Position
16+
--- @return VisualRange
17+
function VisualRange:new(mode, from, to)
18+
-- normalize row/col order
19+
if to < from then
20+
from, to = to, from
21+
end
22+
23+
local o = { mode = mode, from = from, to = to }
24+
setmetatable(o, { __index = self })
25+
return o
26+
end
27+
28+
--- Returns true if the `VisualRange` does not span across multiple lines.
29+
--- @return boolean
30+
function VisualRange:is_single_line()
31+
return self.from.row == self.to.row
32+
end
33+
34+
return VisualRange
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
--- Context: Visual selections relate to windows. The same buffer could be open
2+
--- in different windows and be in different modes with different cursor
3+
--- positions.
4+
5+
local VisualRange = require("zettelkasten.utils.nvim.visualrange")
6+
7+
--- @class zettelkasten.utils.nvim.WindowBuffer
8+
--- @field window_id integer
9+
--- @field buffer_id integer
10+
local WindowBuffer = {}
11+
12+
function WindowBuffer:new(window_id)
13+
assert(type(window_id) == "number" and window_id % 1 == 0 and window_id ~= 0)
14+
local buffer_id = vim.api.nvim_win_get_buf(window_id)
15+
16+
return { window_id = window_id, buffer_id = buffer_id }
17+
end
18+
19+
function WindowBuffer:current_window()
20+
local window_id = vim.api.nvim_win_get_number(0)
21+
return self:new(window_id)
22+
end
23+
24+
function WindowBuffer:mode()
25+
local cur_win = vim.api.nvim_get_current_win()
26+
vim.api.nvim_set_current_win(self.window_id)
27+
local mode = vim.api.nvim_get_mode().mode
28+
vim.api.nvim_set_current_win(cur_win)
29+
return mode
30+
end
31+
32+
function WindowBuffer:selection_start()
33+
local cur_win = vim.api.nvim_get_current_win()
34+
vim.api.nvim_set_current_win(self.window_id)
35+
local position = Position
36+
vim.api.nvim_set_current_win(cur_win)
37+
return mode
38+
end
39+
40+
--- Returns the visual selection range.
41+
--- @return VisualRange
42+
function WindowBuffer:visual_range()
43+
local mode = self:mode()
44+
local from = self:selection_start()
45+
local to = self:cursor_position()
46+
return VisualRange:new(mode, from, to)
47+
end
48+
49+
local M = {}
50+
51+
function M.new(window_id)
52+
assert(type(window_id) == "number" and window_id % 1 == 0 and window_id ~= 0)
53+
local buffer_id = vim.api.nvim_win_get_buf(window_id)
54+
55+
local o = { window_id = window_id, buffer_id = buffer_id }
56+
setmetatable(o, { __index = WindowBuffer })
57+
return o
58+
end
59+
60+
function M.current_window()
61+
local window_id = vim.api.nvim_win_get_number(0)
62+
return M.new(window_id)
63+
end
64+
65+
return WindowBuffer
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
local M = {}
2+
3+
--- Returns the path relative to it's parent directory.
4+
--- @param path string
5+
--- @return string
6+
function M.relative_to_parent_directory(path)
7+
return "./" .. path:match("([^/\\]+)$")
8+
end
9+
10+
return M

myplugins/zettelkasten/lua/zettelkasten/utils/table.lua

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
local M = {}
22

3+
--- Returns a shallow copy of table `t`.
4+
---
5+
--- @param t table
6+
M.shallow_copy = function(t)
7+
local copy = {}
8+
9+
for index, value in ipairs(t) do
10+
copy[index] = value
11+
end
12+
13+
for key, value in pairs(t) do
14+
copy[key] = value
15+
end
16+
17+
return copy
18+
end
19+
320
--- Mutates `dst` by appending the elements from `src` in order. Ignores
421
--- non-numeric keys.
522
---

0 commit comments

Comments
 (0)