diff --git a/README.md b/README.md
index 885ddac..8a29806 100644
--- a/README.md
+++ b/README.md
@@ -7,13 +7,17 @@
## Features
-- **Open PDF Files**: Use Telescope to quickly search and open PDF files.
+- **Open PDF Files**: Quickly search and open PDF files using the currently supported pickers: `telescope.nvim`, `fzf-lua`, or the built-in `vim.ui.select`.
- **Extract Text**: Extract the text from a PDF using `pdftotext` for easy reading or note-taking.
- **Pagination**: Navigate through the document using next/previous page commands.
- **Customizable Pagination**: Set how many lines per page should be displayed.
- **Virtual Text Display**: See page numbers displayed in the buffer.
+- **Bookmarks**: Save your reading position with a bookmark and jump back to it anytime.
+
+
+
+
-
---
@@ -22,8 +26,12 @@
### Prerequisites
- **Neovim** version 0.5 or higher
-- **Telescope.nvim**: The plugin uses Telescope to search for PDF files.
-- **pdftotext**: This plugin relies on the `pdftotext` command-line tool to extract text from PDFs. Install `pdftotext` using the following command:
+- **Picker**: **Telescope.nvim** (optional), **Fzf-lua** (optional), or the **default** picker (`vim.ui.select`)
+- **[Zathura](https://github.com/pwmt/zathura)**: document viewer
+ ```bash
+ sudo apt install zathura
+ ```
+- **pdftotext**: this plugin relies on the `pdftotext` command-line tool to extract text from PDFs. Install it using the following command:
```bash
sudo apt install poppler-utils
```
@@ -35,16 +43,57 @@ To install `PDFview.nvim` using **LazyVim**, add the following configuration to
```lua
{
"basola21/PDFview",
- lazy = false,
- dependencies = { "nvim-telescope/telescope.nvim" }
-}
+ event = "VeryLazy",
+ dependencies = {
+ "nvim-telescope/telescope.nvim"
+ },
+ opts = {
+ path = "/path/to/pdf_folder", -- path pdf folder
+ save = "/path/to/save_folder", -- bookmark save folder
+ picker = "default", -- fzf-lua, telescope, default (using vim.ui.select)
+ open = {
+ cb = nil,
+ },
+ window = {
+ winhighlight = nil,
+ },
+ keymaps = {
+ menu = "",
+ go_to_page = "gf",
+ show_page_in_zathura = "x",
+ next_page = "",
+ prev_page = "",
+ bookmark = "b",
+ save = "s",
+ },
+ keys = {
+ {
+ "fp",
+ function()
+ require("pdfview").select_file_pdf()
+ end,
+ desc = "Select pdf file",
+ },
+ {
+ "fP",
+ function()
+ require("pdfview").menu()
+ end,
+ desc = "Open menu",
+ },
+ },
+},
```
### Mappings
-You can add the following key mappings to your Neovim configuration for easy navigation through the PDF pages:
+The `next_page` and `prev_page` keymaps are already set up for you in the `opts.keymaps` table above.
+However, if you'd rather define your own custom keys instead of using the built-in ones, you can call the underlying functions directly:
```lua
+-- Open menu pdfview
+map("n", "ff", ":lua require('pdfview').menu()", { desc = "PDFview: Menu" })
+
-- Navigate to the next page in the PDF
map("n", "jj", ":lua require('pdfview.renderer').next_page()", { desc = "PDFview: Next page" })
@@ -59,14 +108,16 @@ map("n", "kk", ":lua require('pdfview.renderer').previous_page()jj` for the next page.
- - `kk` for the previous page.
+ - `` or `jj` for the next page.
+ - `` or `kk` for the previous page.
3. **Extracting Text from a PDF**
When you select a PDF using Telescope, the plugin extracts the text using `pdftotext` and displays it in a buffer, allowing for easy reading or note-taking.
@@ -86,14 +137,63 @@ map("n", "kk", ":lua require('pdfview.renderer').previous_page()"
+ end,
+ },
+```
+
+### Support Statusline
+
+Example using `heirline.nvim`:
+
+```lua
+local get_pdfview = function()
+ if not pdfview then
+ local ok, session = pcall(require, "pdfview.renderer")
+ if ok then
+ pdfview = session
+ end
+ end
+ return pdfview
+end
+
+
+...
+
+{
+ ...
+ provider = function()
+ if vim.bo.filetype == "pdfview" then
+ if not pdfview then
+ pdfview = get_pdfview()
+ end
+
+ local pdf_render = pdfview.get()
+ if pdf_render and pdf_render.pdf_path then
+ return vim.fn.fnamemodify(pdf_render.pdf_path, ":~") .. " (Page " .. pdf_render.current_page .. ")"
+ end
+ end
+ end,
+}
+
+```
---
## Planned Features
- **Improved Navigation**: Refine the pagination and scrolling behavior for a smoother reading experience.
-- **Customization**: Add options for setting default configurations like `lines_per_page` and buffer options.
- **Document Search**: Implement a search feature to find specific text within the PDF.
- **Improved Structure**: Enhance the project structure for better maintainability and scalability.
diff --git a/TODO.org b/TODO.org
new file mode 100644
index 0000000..0872061
--- /dev/null
+++ b/TODO.org
@@ -0,0 +1,11 @@
+* TODO
+ - [x] define bookmark save path
+ - [x] implement bookmark menu
+ - [x] implement bookmark saving
+ - [x] implement last bookmark
+
+ - [X] implement text search
+ - [ ] implement snacks picker
+ - [ ] implement toggle case_sensitive
+ - [ ] implement popup search status
+ - [ ] update readme
diff --git a/lua/pdfview/config.lua b/lua/pdfview/config.lua
new file mode 100644
index 0000000..4abd246
--- /dev/null
+++ b/lua/pdfview/config.lua
@@ -0,0 +1,51 @@
+local M = {}
+
+---@type PDFviewCfg
+M.defaults = {
+ path = vim.fn.stdpath "config",
+ save = vim.fn.stdpath "state",
+ picker = "default",
+ open = {
+ cb = nil,
+ },
+ window = {
+ winhighlight = nil,
+ text_search_indicator = {
+ sep_front = "",
+ sep_back = "",
+ hl = "WarningMsg",
+ attr = "fg",
+ icon = " ",
+ },
+ },
+ keymaps = {
+ menu = "",
+ go_to_page = "gf",
+ show_page_in_zathura = "x",
+ next_page = "",
+ prev_page = "",
+ open_bookmark = "b",
+ save_bookmark = "s",
+ search = "",
+ pick_search = "s",
+ next_search_text = "",
+ prev_search_text = "",
+ },
+}
+
+---@param cfg_tbl PDFviewCfg
+---@param opts PDFviewCfg
+local function merge_settings(cfg_tbl, opts)
+ opts = opts or {}
+ local def = vim.tbl_deep_extend("force", cfg_tbl, opts)
+ return def
+end
+
+---@param opts PDFviewCfg
+function M.update_settings(opts)
+ opts = opts or {}
+
+ M.defaults = merge_settings(M.defaults, opts)
+end
+
+return M
diff --git a/lua/pdfview/init.lua b/lua/pdfview/init.lua
index 5264233..296a11f 100644
--- a/lua/pdfview/init.lua
+++ b/lua/pdfview/init.lua
@@ -1,55 +1,260 @@
-local parser = require("pdfview.parser")
-local renderer = require("pdfview.renderer")
-local telescope = require("telescope.builtin")
-local previewers = require("telescope.previewers")
-local actions = require("telescope.actions")
-local action_state = require("telescope.actions.state")
+local Config = require "pdfview.config"
+local Util = require "pdfview.utils"
+
+local parser = require "pdfview.parser"
+local renderer = require "pdfview.renderer"
+local search = require "pdfview.search"
+local picker = require "pdfview.pickers"
local M = {}
+---@param opts PDFviewCfg
+function M.setup(opts)
+ Config.update_settings(opts)
+
+ Config.defaults.group = "PDFview"
+ Config.defaults.save = Config.defaults.save .. "/pdfview.lua"
+
+ Util.clear_autocmd_group(Config.defaults.group)
+ local group = vim.api.nvim_create_augroup(Config.defaults.group, { clear = true })
+
+ local keymaps = require "pdfview.keymaps"
+ keymaps.setup_filetype_mappings(group, renderer.get())
+end
+
+---@param pdf_path string
+local function ensure_callback(pdf_path)
+ if Config.defaults.open and Config.defaults.open.cb then
+ if type(Config.defaults.open.cb) ~= "function" then
+ return
+ end
+ Config.defaults.open.cb(pdf_path)
+ end
+end
+
+function M.select_file_pdf()
+ local path = Config.defaults.path
+ if not Util.is_dir(path) then
+ return
+ end
+
+ picker.select(Config.defaults.picker or "default", "files", path, function(pdf_path)
+ if not Util.is_file(pdf_path) then
+ Util.warn("PDF path doesn't exist: `" .. pdf_path .. "`.")
+ return
+ end
+
+ Config.defaults.pdf_path = pdf_path
+ M.open(pdf_path)
+ ensure_callback(pdf_path)
+ end)
+end
+
+function M.select_bookmark()
+ local file_saved = Config.defaults.save
+ if not Util.is_file(file_saved) then
+ Util.warn "Bookmark save file not found. Please create one first."
+ return
+ end
+
+ picker.select(Config.defaults.picker or "default", "bookmark", file_saved, function(opts)
+ Config.defaults.pdf_path = opts.pdf_path
+ M.open(opts.pdf_path, opts)
+ ensure_callback(opts.pdf_path)
+ end)
+end
+
+function M.select_text_search()
+ local path = Config.defaults.path
+ if not Util.is_dir(path) then
+ return
+ end
+
+ picker.select(Config.defaults.picker or "default", "search", path, nil)
+end
+
+function M.open_from_last_bookmark()
+ local pdf_bookmarks = Util.get_pdf_bookmarks()
+ if not pdf_bookmarks then
+ return
+ end
+
+ local opts = pdf_bookmarks[1]
+ Config.defaults.pdf_path = opts.pdf_path
+ M.open(opts.pdf_path, opts)
+ ensure_callback(opts.pdf_path)
+end
+
+function M.history()
+ Util.not_implemented_yet()
+end
+
+function M.menu()
+ local ui = require "pdfview.ui"
+ ui.call("menu", Config.defaults)
+end
+
+---@param page_num number|nil
+---@param state? PDFviewStateRender
+---@param notify? boolean
+function M.go_to(page_num, state, notify)
+ state = state or renderer.get()
+ notify = notify or false
+
+ if page_num then
+ state.current_page = page_num
+ renderer.display_current_page()
+ if notify then
+ Util.info(string.format("Go to page: %d", state.current_page))
+ end
+ return
+ end
+
+ vim.ui.input({
+ prompt = "Go to page: ",
+ }, function(input)
+ if not input then
+ return
+ end
+
+ local num = tonumber(input)
+ if not num then
+ Util.warn("go_to", "not a number `" .. input .. "`")
+ return
+ end
+
+ state.current_page = num
+
+ if notify then
+ Util.info(string.format("Go to page: %d", state.current_page))
+ end
+ renderer.display_current_page()
+ end)
+end
+
+---@param page_num number|nil
+---@param state? PDFviewStateRender
+function M.open_in_zathura(page_num, state)
+ state = state or renderer.get()
+
+ if not page_num then
+ page_num = state.current_page
+ end
+
+ if not Util.is_file(state.pdf_path) or state.pdf_path == "" then
+ return
+ end
+
+ local zathura_cmd = { "zathura", "-P", tostring(page_num), state.pdf_path }
+ Util.system_command(zathura_cmd)
+end
+
+local last_open_pdf
+
-- Function to open the full PDF text (runs when PDF is selected)
-function M.open(pdf_path)
- local text = parser.extract_text(pdf_path)
- if text then
- renderer.display_text(text)
- end
+---@param pdf_path string
+---@param opts? table
+function M.open(pdf_path, opts)
+ opts = opts or {}
+
+ if not opts.pdf_path then
+ opts.pdf_path = pdf_path
+ end
+
+ if not opts.last_page then
+ opts.last_page = 1
+ end
+
+ if last_open_pdf and last_open_pdf == opts.pdf_path and vim.bo.filetype == "pdfview" then
+ M.go_to(opts.last_page)
+ return
+ end
+
+ last_open_pdf = opts.pdf_path
+
+ local text = parser.extract_text(opts.pdf_path)
+ if text then
+ renderer.display_text(text, opts.last_page)
+ local pdffile = vim.fn.fnamemodify(opts.pdf_path, ":~")
+ Util.info("Loaded PDF: `" .. pdffile .. "`")
+ end
+end
+
+---@param pdf_path string
+---@param query string
+---@param state? PDFviewStateRender
+local function search_to(pdf_path, query, state)
+ state = state or renderer.get()
+
+ local matches
+ if state.search and state.search.cache and state.search.cache[query] then
+ matches = state.search.cache[query]
+ else
+ matches = search.find_matches(pdf_path, query, state)
+ end
+
+ if #matches == 0 then
+ Util.warn("no matches for '" .. query .. "'")
+ return
+ end
+
+ if not state.win then
+ state.win = vim.api.nvim_get_current_win()
+ end
+
+ state.search = state.search or { cache = {} }
+ state.search.cache[query] = matches
+ state.search.current_query = query
+ state.ns_search_id = vim.api.nvim_create_namespace "pdfview-search"
+
+ Util.info(string.format("Found `%d %s` for query `%s`", #matches, (#matches == 1) and "match" or "matches", query))
+
+ -- debug: test open item matches in quickfix..
+ -- local qf_items = {}
+ -- for _, m in ipairs(matches) do
+ -- table.insert(qf_items, {
+ -- filename = m.filename,
+ -- lnum = m.page,
+ -- col = m.col,
+ -- text = m.text_line,
+ -- page = m.line,
+ -- })
+ -- end
+ -- vim.fn.setqflist({}, " ", { title = "PDFview: " .. query, items = qf_items })
+ -- vim.cmd "copen"
+end
+
+---@param pdf_path? string
+---@param query? string
+function M.text_search(pdf_path, query)
+ if pdf_path and query then
+ search_to(pdf_path, query)
+ end
+
+ vim.ui.input({
+ prompt = "Text Search: ",
+ }, function(q)
+ if not q then
+ return
+ end
+
+ local state = renderer.get()
+ if state and state.pdf_path then
+ search_to(state.pdf_path, q)
+ end
+ end)
end
-- Function to extract and display the first page (used for preview)
+---@param pdf_path string
+---@return string
function M.preview_first_page(pdf_path)
- local first_page_text = parser.extract_text(pdf_path, 1, 1)
- if first_page_text then
- return first_page_text
- else
- return "Could not extract text from the first page of the PDF."
- end
-end
-
--- Custom previewer for Telescope to show the first page
-local pdf_previewer = previewers.new_buffer_previewer({
- define_preview = function(self, entry)
- local pdf_path = entry.path
- local preview_text = M.preview_first_page(pdf_path)
- vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, vim.split(preview_text, "\n"))
- end,
-})
-
--- Telescope function with preview and open functionality
-function M.telescope_open()
- telescope.find_files({
- prompt_title = "Select PDF",
- find_command = { "find", ".", "-type", "f", "-name", "*.pdf" },
- previewer = pdf_previewer,
- attach_mappings = function(_, map)
- map("i", "", function(prompt_bufnr)
- local selected_file = action_state.get_selected_entry(prompt_bufnr)
- actions.close(prompt_bufnr)
- local pdf_path = selected_file.path
- M.open(pdf_path)
- end)
- return true
- end,
- })
+ local first_page_text = parser.extract_text(pdf_path, 1, 1)
+ if first_page_text then
+ return first_page_text
+ else
+ return "Could not extract text from the first page of the PDF."
+ end
end
return M
diff --git a/lua/pdfview/keymaps.lua b/lua/pdfview/keymaps.lua
new file mode 100644
index 0000000..e2a8f50
--- /dev/null
+++ b/lua/pdfview/keymaps.lua
@@ -0,0 +1,170 @@
+local Config = require "pdfview.config"
+local Util = require "pdfview.utils"
+local renderer = require "pdfview.renderer"
+
+local M = {}
+
+---@param state PDFviewStateRender
+local function save(state)
+ local file_saved = Config.defaults.save
+ if not Util.is_file(file_saved) then
+ Util.create_file(file_saved)
+ end
+
+ local pdf_bookmarks = Util.get_pdf_bookmarks()
+ if not pdf_bookmarks then
+ return
+ end
+
+ local inserted_at = os.time()
+
+ ---@type PDFviewBookmarkSaved
+ local _data = {
+ last_page = state.current_page,
+ real_page = state.current_page + (state.page_offset or 0),
+ total_pages = state.total_pages + (state.page_offset or 0),
+ pdf_path = Config.defaults.pdf_path,
+ created_at = inserted_at,
+ text_page = "Page " .. state.current_page,
+ text_path = vim.fn.fnamemodify(Config.defaults.pdf_path, ":~"),
+ }
+
+ pdf_bookmarks[#pdf_bookmarks + 1] = _data
+
+ table.sort(pdf_bookmarks, function(a, b)
+ return (a.created_at or 0) > (b.created_at or 0)
+ end)
+
+ Util.save_table_to_file(pdf_bookmarks, file_saved)
+ Util.info("bookmark", string.format("Saved: %s · %s", _data.text_page, _data.text_path))
+end
+
+local idx = 1
+
+---@param state PDFviewStateRender
+---@param step integer
+---@param bufnr integer
+local function search(state, step, bufnr)
+ if not state.search or not state.search.cache or not state.search.current_query then
+ return
+ end
+
+ local items = state.search.cache[state.search.current_query]
+
+ local total_items = #items
+ idx = ((idx - 1 + step) % total_items) + 1
+ local item = items[idx]
+
+ require("pdfview").go_to(item.page, state)
+ Util.__add_buf_highlight(item, state, idx, total_items)
+
+ --- Attach a keybinding to delete search indicators.
+ if state.win_status_search_indicator and vim.api.nvim_win_is_valid(state.win_status_search_indicator) then
+ vim.keymap.set("n", "", function()
+ if state.win_status_search_indicator and vim.api.nvim_win_is_valid(state.win_status_search_indicator) then
+ vim.api.nvim_win_close(state.win_status_search_indicator, true)
+ pcall(vim.keymap.del, "n", "", { buffer = bufnr })
+ end
+ end)
+ vim.keymap.set("n", "q", function()
+ if state.win_status_search_indicator and vim.api.nvim_win_is_valid(state.win_status_search_indicator) then
+ vim.api.nvim_win_close(state.win_status_search_indicator, true)
+ pcall(vim.keymap.del, "n", "q", { buffer = bufnr })
+ end
+ end)
+ end
+end
+
+---@param state PDFviewStateRender
+---@param bufnr integer
+local function next_search_text(state, bufnr)
+ search(state, 1, bufnr)
+end
+
+---@param state PDFviewStateRender
+---@param bufnr integer
+local function prev_search_text(state, bufnr)
+ search(state, -1, bufnr)
+end
+
+---@param ctx vim.api.keyset.create_autocmd.callback_args
+---@param state PDFviewStateRender
+local function setup_pdfview_ft_mappings(ctx, state)
+ local pdfview = require "pdfview"
+ local keymaps = Config.defaults.keymaps
+ local bufnr = ctx.buf
+
+ -- stylua: ignore
+ ---@type PDFviewKeySpec[]
+ local _keys = {
+ { key = keymaps.go_to_page, fun = function() pdfview.go_to(nil, state, true) end, desc = "go to page", buf = bufnr },
+ { key = keymaps.show_page_in_zathura, fun = function() pdfview.open_in_zathura(nil, state) end, desc = "show page in Zathura", buf = bufnr },
+ { key = keymaps.next_page, fun = renderer.next_page, desc = "next page", buf = bufnr },
+ { key = keymaps.prev_page, fun = renderer.previous_page, desc = "previous page", buf = bufnr },
+ { key = keymaps.open_bookmark, fun = function() pdfview.select_bookmark() end, desc = "select bookmarks", buf = bufnr },
+ { key = keymaps.save_bookmark, fun = function() save(state) end, desc = "save bookmark", buf = bufnr },
+ { key = keymaps.menu, fun = function() pdfview.menu() end, desc = "open menu", buf = bufnr },
+
+ { key = keymaps.search, fun = function() pdfview.search_text() end, desc = "search text", buf = bufnr },
+ { key = keymaps.pick_search, fun = function() pdfview.select_search() end, desc = "select search result", buf = bufnr },
+ { key = keymaps.next_search_text, fun = function() next_search_text(state, bufnr) end, desc = "next search result", buf = bufnr },
+ { key = keymaps.prev_search_text, fun = function() prev_search_text(state, bufnr) end, desc = "previous search result", buf = bufnr },
+ }
+
+ M.append_to(_keys)
+
+ local augroup = Util.create_augroup_name("SearchCleanup_" .. state.buf)
+ vim.api.nvim_create_autocmd({ "BufWipeout", "BufUnload" }, {
+ group = augroup,
+ buffer = state.buf,
+ once = true,
+ callback = function()
+ if vim.api.nvim_buf_is_valid(bufnr) then
+ if state.search then
+ state.search = nil
+ end
+ if state.pages then
+ state.pages = nil
+ end
+ end
+ end,
+ })
+end
+
+---@param group integer
+---@param state PDFviewStateRender
+local function augroup(group, state, cb)
+ vim.api.nvim_create_autocmd("FileType", {
+ group = group,
+ pattern = state.filetype,
+ desc = "PDFview filetype mappings",
+ callback = function(ctx)
+ cb(ctx)
+ end,
+ })
+end
+
+---@param tbl_keys PDFviewKeySpec
+function M.append_to(tbl_keys)
+ for _, k in pairs(tbl_keys) do
+ local keys = type(k.key) == "string" and { k.key } or k.key
+ for _, key in ipairs(keys) do
+ if type(k.fun) == "function" then
+ vim.keymap.set("n", key, k.fun, {
+ desc = k.desc,
+ buffer = k.buf,
+ })
+ end
+ end
+ end
+end
+
+---@param group integer
+---@param state PDFviewStateRender
+function M.setup_filetype_mappings(group, state)
+ augroup(group, state, function(ctx)
+ setup_pdfview_ft_mappings(ctx, state)
+ end)
+end
+
+return M
diff --git a/lua/pdfview/parser.lua b/lua/pdfview/parser.lua
index 7fe69a5..2d3f0fc 100644
--- a/lua/pdfview/parser.lua
+++ b/lua/pdfview/parser.lua
@@ -1,40 +1,41 @@
+local Util = require "pdfview.utils"
local M = {}
-- Check if the PDF file exists
-local function file_exists(pdf_path)
- return vim.fn.filereadable(pdf_path) ~= 0
-end
+-- local function file_exists(pdf_path)
+-- return vim.fn.filereadable(pdf_path) ~= 0
+-- end
-- Build the shell command for extracting text
local function build_command(pdf_path, start_page, end_page)
- local escaped_pdf_path = vim.fn.shellescape(pdf_path)
- local page_args = ""
- if start_page and end_page then
- page_args = string.format("-f %d -l %d", start_page, end_page)
- end
- return string.format("pdftotext -layout %s %s -", page_args, escaped_pdf_path)
+ local escaped_pdf_path = vim.fn.shellescape(pdf_path)
+ local page_args = ""
+ if start_page and end_page then
+ page_args = string.format("-f %d -l %d", start_page, end_page)
+ end
+ return string.format("pdftotext -layout %s %s -", page_args, escaped_pdf_path)
end
-- Run the shell command and return the output
local function run_command(cmd)
- local result = vim.fn.system(cmd)
- if result and #result > 0 then
- return result
- else
- vim.api.nvim_err_writeln("PDFview: Failed to extract text from PDF.")
- return nil
- end
+ local result = vim.fn.system(cmd)
+ if result and #result > 0 then
+ return result
+ else
+ Util.error("parser", "Failed to extract text from PDF.")
+ return nil
+ end
end
-- Main function to extract text from the PDF with optional page range
function M.extract_text(pdf_path, start_page, end_page)
- if not file_exists(pdf_path) then
- vim.api.nvim_err_writeln("PDFview: File does not exist: " .. pdf_path)
- return nil
- end
+ if not Util.is_file(pdf_path) then
+ Util.error("parser", "File does not exist: " .. pdf_path)
+ return nil
+ end
- local cmd = build_command(pdf_path, start_page, end_page)
- return run_command(cmd)
+ local cmd = build_command(pdf_path, start_page, end_page)
+ return run_command(cmd)
end
return M
diff --git a/lua/pdfview/pickers/default.lua b/lua/pdfview/pickers/default.lua
new file mode 100644
index 0000000..e7c2172
--- /dev/null
+++ b/lua/pdfview/pickers/default.lua
@@ -0,0 +1,128 @@
+local Util = require "pdfview.utils"
+local UtilPicker = require "pdfview.pickers.utils"
+
+local M = {}
+
+local function list_files(path)
+ local cmd = UtilPicker.find_command(path)
+ local files = vim.fn.systemlist(cmd)
+ return files
+end
+
+---@param path string
+---@param cb function
+function M.files(path, cb)
+ local files = list_files(path)
+ vim.ui.select(files, { prompt = Util.format_title "pdf files" }, function(file)
+ if not file then
+ return
+ end
+
+ local pdf_path = vim.fs.normalize(file)
+ cb(pdf_path)
+ end)
+end
+
+---@param path string
+---@param cb function
+function M.bookmark(path, cb)
+ local pdf_bookmarks = Util.get_pdf_bookmarks()
+ if not pdf_bookmarks then
+ return
+ end
+
+ local contents = UtilPicker.bookmark_contents(pdf_bookmarks)
+ if Util.is_blank(contents) then
+ return
+ end
+
+ vim.ui.select(contents, { prompt = Util.format_title "bookmarks" }, function(selection)
+ if not selection then
+ return
+ end
+
+ local sel = vim.split(selection, "·")
+
+ local sel_page_num = Util.strip_whitespace(sel[1])
+ local sel_pdf_path = Util.strip_whitespace(sel[2])
+
+ for i, _pdf in pairs(pdf_bookmarks) do
+ if _pdf.text_page == sel_page_num and _pdf.text_path == sel_pdf_path then
+ return cb(pdf_bookmarks[i])
+ end
+ end
+ end)
+end
+
+function M.delete_item_bookmark()
+ local pdf_bookmarks = Util.get_pdf_bookmarks()
+ if not pdf_bookmarks then
+ return
+ end
+
+ local contents = UtilPicker.bookmark_contents(pdf_bookmarks)
+ if Util.is_blank(contents) then
+ return
+ end
+
+ vim.ui.select(contents, { prompt = Util.format_title "delete bookmarks" }, function(selection)
+ if not selection then
+ return
+ end
+
+ local sel = vim.split(selection, "·")
+
+ local sel_page_num = Util.strip_whitespace(sel[1])
+ local sel_pdf_path = Util.strip_whitespace(sel[2])
+ local file_saved = require("pdfview.config").defaults.save
+
+ for i, _pdf in pairs(pdf_bookmarks) do
+ if _pdf.text_page == sel_page_num and _pdf.text_path == sel_pdf_path then
+ table.remove(pdf_bookmarks, i)
+
+ table.sort(pdf_bookmarks, function(a, b)
+ return (a.created_at and a.created_at or 0) > (b.created_at and b.created_at or 0)
+ end)
+
+ Util.save_table_to_file(pdf_bookmarks, file_saved)
+ Util.info(_pdf.text_path .. " removed.")
+ return
+ end
+ end
+ end)
+end
+
+function M.search()
+ local renderer = require "pdfview.renderer"
+ local state = renderer.get()
+
+ local data = UtilPicker.search_cache(state)
+ if not data then
+ Util.warn("picker", "No active search")
+ return
+ end
+
+ local contents = data.contents
+ local seen = data.seen
+
+ vim.ui.select(
+ contents,
+ { prompt = Util.format_title "" },
+ function(selection)
+ if not selection then
+ return
+ end
+
+ local sel = selection
+ local item = seen[vim.trim(sel)]
+ if not item then
+ return
+ end
+
+ require("pdfview").go_to(item.page, state, true)
+ Util.__add_buf_highlight(item, state)
+ end
+ )
+end
+
+return M
diff --git a/lua/pdfview/pickers/fzf-lua.lua b/lua/pdfview/pickers/fzf-lua.lua
new file mode 100644
index 0000000..75c39bb
--- /dev/null
+++ b/lua/pdfview/pickers/fzf-lua.lua
@@ -0,0 +1,209 @@
+local Util = require "pdfview.utils"
+local UtilPicker = require "pdfview.pickers.utils"
+
+local M = {}
+
+local loaded = false
+local FzfLua
+
+local silent_notify = false
+
+local function setup_fzflua()
+ if loaded then
+ return FzfLua
+ end
+
+ local ok, _ = pcall(require, "fzf-lua")
+ if not ok then
+ if not silent_notify then
+ Util.error "This extension requires ibhagwan/fzf-lua (https://github.com/ibhagwan/fzf-lua)"
+ silent_notify = true
+ return
+ end
+ return
+ end
+
+ FzfLua = require "fzf-lua"
+ loaded = true
+
+ return FzfLua
+end
+
+local Mapping = {}
+
+---@param path string
+---@param cb function
+function Mapping.default(path, cb)
+ return function(selection)
+ if not selection then
+ return
+ end
+
+ local sel = selection[1]
+
+ local pdf_path = path .. "/" .. sel
+ pdf_path = vim.fs.normalize(pdf_path)
+ cb(pdf_path)
+ end
+end
+
+---@param pdf_bookmarks PDFviewBookmarkSaved[]
+---@param cb function
+function Mapping.default_bookmark(pdf_bookmarks, cb)
+ return function(selection)
+ if not selection then
+ return
+ end
+
+ local sel = vim.split(selection[1], "·")
+
+ local sel_page_num = Util.strip_whitespace(sel[1])
+ local sel_pdf_path = Util.strip_whitespace(sel[2])
+
+ for i, _pdf in pairs(pdf_bookmarks) do
+ if _pdf.text_page == sel_page_num and _pdf.text_path == sel_pdf_path then
+ return cb(pdf_bookmarks[i])
+ end
+ end
+ end
+end
+
+---@param pdf_bookmarks PDFviewBookmarkSaved[]
+function Mapping.delete_bookmark(pdf_bookmarks)
+ return function(selection)
+ if not selection then
+ return
+ end
+
+ local sel = vim.split(selection[1], "·")
+
+ local sel_page_num = Util.strip_whitespace(sel[1])
+ local sel_pdf_path = Util.strip_whitespace(sel[2])
+ local file_saved = require("pdfview.config").defaults.save
+
+ for i, _pdf in pairs(pdf_bookmarks) do
+ if _pdf.text_page == sel_page_num and _pdf.text_path == sel_pdf_path then
+ table.remove(pdf_bookmarks, i)
+
+ table.sort(pdf_bookmarks, function(a, b)
+ return (a.created_at and a.created_at or 0) > (b.created_at and b.created_at or 0)
+ end)
+
+ Util.save_table_to_file(pdf_bookmarks, file_saved)
+ Util.info(_pdf.text_path .. " removed.")
+
+ -- unplanned: should resume or exit?
+ -- FzfLua.actions.resume()
+ return
+ end
+ end
+ end
+end
+
+---@param state PDFviewStateRender
+---@param seen table
+function Mapping.search(state, seen)
+ return function(selection)
+ if not selection then
+ return
+ end
+
+ local sel = selection[1]
+ if not sel then
+ return
+ end
+
+ local item = seen[vim.trim(sel)]
+ if not item then
+ return
+ end
+
+ require("pdfview").go_to(item.page, state, true)
+ Util.__add_buf_highlight(item, state)
+ end
+end
+
+---@return boolean
+function M.is_available()
+ return (pcall(require, "fzf-lua"))
+end
+
+---@param path string
+---@param cb function
+function M.files(path, cb)
+ setup_fzflua()
+
+ if not loaded then
+ return
+ end
+
+ FzfLua.files {
+ cwd = path,
+ no_header = true,
+ no_header_i = true,
+ -- fzf_opts = { ["--header"] = [[^x:delete ^r:rename]] },
+ winopts = { title = Util.format_title "pdf files", preview = { hidden = false } },
+ actions = {
+ ["default"] = Mapping.default(path, cb),
+ },
+ }
+end
+
+---@param path string
+---@param cb function
+function M.bookmark(path, cb)
+ setup_fzflua()
+
+ if not loaded then
+ return
+ end
+
+ local pdf_bookmarks = Util.get_pdf_bookmarks()
+ if not pdf_bookmarks then
+ return
+ end
+
+ local contents = UtilPicker.bookmark_contents(pdf_bookmarks)
+ if Util.is_blank(contents) then
+ return
+ end
+
+ FzfLua.fzf_exec(contents, {
+ no_header = true,
+ no_header_i = true,
+ fzf_opts = { ["--header"] = [[ delete]] },
+ winopts = { title = Util.format_title "bookmarks", preview = { hidden = true } },
+ actions = {
+ ["default"] = Mapping.default_bookmark(pdf_bookmarks, cb),
+ ["ctrl-x"] = Mapping.delete_bookmark(pdf_bookmarks),
+ },
+ })
+end
+
+function M.search()
+ setup_fzflua()
+
+ local renderer = require "pdfview.renderer"
+ local state = renderer.get()
+
+ local data = UtilPicker.search_cache(state)
+ if not data then
+ Util.warn("picker.fzf-lua", "No active search")
+ return
+ end
+
+ local contents = data.contents
+ local seen = data.seen
+
+ FzfLua.fzf_exec(contents, {
+ no_header = true,
+ no_header_i = true,
+ fzf_opts = { ["--header"] = [[:delete]] },
+ winopts = { title = Util.format_title "", preview = { hidden = true } },
+ actions = {
+ ["default"] = Mapping.search(state, seen),
+ },
+ })
+end
+
+return M
diff --git a/lua/pdfview/pickers/init.lua b/lua/pdfview/pickers/init.lua
new file mode 100644
index 0000000..7b4746a
--- /dev/null
+++ b/lua/pdfview/pickers/init.lua
@@ -0,0 +1,87 @@
+local M = {}
+
+local Util = require "pdfview.utils"
+
+-- NOTE: For maintainers: if you add a new picker interface, make sure to register it here as well.
+local AUTO_PICKER_PRIORITY = { "fzf-lua", "snacks", "telescope", "default" }
+
+local silent_warn_notify = false
+local warn_msg_picker_not_installed
+local set_picker
+
+---@return string
+local function default_picker()
+ for _, name in ipairs(AUTO_PICKER_PRIORITY) do
+ local ok, picker = pcall(require, string.format("pdfview.pickers.%s", name))
+ if ok and picker.is_available and picker.is_available() then
+ return name
+ end
+ end
+ return "default"
+end
+
+---@param picker_name string?
+local function resolve_picker(picker_name)
+ picker_name = picker_name or ""
+
+ if Util.is_blank(picker_name) then
+ picker_name = default_picker()
+ end
+
+ local ok, picker = pcall(require, string.format("pdfview.pickers.%s", picker_name))
+
+ if not ok then
+ if not silent_warn_notify then
+ warn_msg_picker_not_installed = string.format("The picker `%s` is not installed", picker_name)
+ end
+
+ set_picker = default_picker()
+ return resolve_picker(set_picker)
+ end
+
+ if picker.is_available and not picker.is_available() then
+ return resolve_picker()
+ end
+
+ if ok and not silent_warn_notify and warn_msg_picker_not_installed then
+ Util.warn(
+ "picker",
+ string.format("%s.\nFalling back to the picker `%s`.", warn_msg_picker_not_installed, picker_name)
+ )
+ silent_warn_notify = true
+ end
+
+ set_picker = picker_name
+ return picker
+end
+
+local resolved_pickers = {}
+
+local function setup_picker(picker_name)
+ local cache_key = picker_name or "__auto__"
+ if resolved_pickers[cache_key] then
+ return resolved_pickers[cache_key]
+ end
+ local picker = resolve_picker(picker_name)
+ resolved_pickers[cache_key] = picker
+ return picker
+end
+
+---@param picker_name string
+---@param method string
+---@param path string
+---@param cb function|nil
+function M.select(picker_name, method, path, cb)
+ local picker = setup_picker(picker_name)
+ if not picker or not picker[method] then
+ Util.warn(
+ "picker",
+ string.format("The picker '%s' does not implement the '%s' method.", picker_name or "auto", method)
+ )
+ return
+ end
+
+ picker[method](path, cb)
+end
+
+return M
diff --git a/lua/pdfview/pickers/snacks.lua b/lua/pdfview/pickers/snacks.lua
new file mode 100644
index 0000000..7e1346b
--- /dev/null
+++ b/lua/pdfview/pickers/snacks.lua
@@ -0,0 +1,198 @@
+local snacks = require "snacks"
+
+local Util = require "pdfview.utils"
+local UtilPicker = require "pdfview.pickers.utils"
+
+local M = {}
+
+-- local function pdf_previewer(ctx)
+-- local item = ctx.item
+-- if not item or not item.file then
+-- return
+-- end
+-- -- local text = Parser.extract_text(item.file, 1, 1) -- preview halaman pertama aja
+--
+-- local text = require("pdfview").preview_first_page(item.file)
+-- local lines = vim.split(text or "", "\n")
+-- ctx.preview:set_lines(lines)
+-- ctx.preview:set_title(vim.fn.fnamemodify(item.file, ":t"))
+-- end
+
+local Mapping = {}
+
+---@param pdf_bookmarks PDFviewBookmarkSaved[]
+---@param cb function
+function Mapping.default_bookmark(pdf_bookmarks, cb, selection)
+ if not selection then
+ return
+ end
+
+ -- local sel = vim.split(selection[1], "·")
+ local sel = selection
+
+ local sel_page_num = Util.strip_whitespace(sel[1])
+ local sel_pdf_path = Util.strip_whitespace(sel[2])
+
+ for i, _pdf in pairs(pdf_bookmarks) do
+ if _pdf.text_page == sel_page_num and _pdf.text_path == sel_pdf_path then
+ return cb(pdf_bookmarks[i])
+ end
+ end
+end
+
+---@param pdf_bookmarks PDFviewBookmarkSaved[]
+---@param selection string
+function Mapping.delete_bookmark(pdf_bookmarks, selection)
+ if not selection then
+ return
+ end
+
+ local sel = vim.split(selection, "·")
+
+ local sel_page_num = Util.strip_whitespace(sel[1])
+ local sel_pdf_path = Util.strip_whitespace(sel[2])
+ local file_saved = require("pdfview.config").defaults.save
+
+ for i, _pdf in pairs(pdf_bookmarks) do
+ if _pdf.text_page == sel_page_num and _pdf.text_path == sel_pdf_path then
+ table.remove(pdf_bookmarks, i)
+
+ table.sort(pdf_bookmarks, function(a, b)
+ return (a.created_at and a.created_at or 0) > (b.created_at and b.created_at or 0)
+ end)
+
+ Util.save_table_to_file(pdf_bookmarks, file_saved)
+ Util.info(_pdf.text_path .. " removed.")
+ return
+ end
+ end
+end
+
+---@param state PDFviewStateRender
+---@param seen table
+function Mapping.search(state, seen, selection)
+ if not selection then
+ return
+ end
+
+ local sel = selection
+ local item = seen[vim.trim(sel)]
+ if not item then
+ return
+ end
+
+ require("pdfview").go_to(item.page, state, true)
+ Util.__add_buf_highlight(item, state)
+end
+
+---@return boolean
+function M.is_available()
+ return (pcall(require, "snacks"))
+end
+
+---@param path string
+---@param cb function
+function M.files(path, cb)
+ snacks.picker.pick {
+ source = "files",
+ cwd = path,
+ title = Util.format_title "pdf files",
+ ft = "pdf",
+ -- preview = pdf_previewer, -- use the default previewer from Snacks
+ confirm = function(picker, item)
+ picker:close()
+ if item then
+ cb(path .. "/" .. item.file)
+ end
+ end,
+ }
+end
+
+---@param path string
+---@param cb function
+function M.bookmark(path, cb)
+ local pdf_bookmarks = Util.get_pdf_bookmarks()
+ if not pdf_bookmarks then
+ return
+ end
+
+ local contents = UtilPicker.bookmark_contents(pdf_bookmarks)
+ if Util.is_blank(contents) then
+ return
+ end
+
+ local items = {}
+ for i, line in ipairs(contents) do
+ table.insert(items, { idx = i, text = line })
+ end
+
+ snacks.picker.pick {
+ title = Util.format_title "bookmarks",
+ items = items,
+ layout = { preset = "select" },
+ format = function(item)
+ return { { item.text } }
+ end,
+ confirm = function(picker, item)
+ picker:close()
+ if item then
+ Mapping.default_bookmark(pdf_bookmarks, cb, item.text)
+ end
+ end,
+ actions = {
+ delete_bookmark = function(picker, item)
+ if not item then
+ return
+ end
+ Mapping.delete_bookmark(pdf_bookmarks, item.text)
+ picker:close()
+ vim.schedule(function()
+ M.bookmark(path, cb)
+ end)
+ end,
+ },
+ win = {
+ input = {
+ keys = {
+ [""] = { "delete_bookmark", mode = { "n", "i" } },
+ },
+ },
+ },
+ }
+end
+
+function M.search()
+ local renderer = require "pdfview.renderer"
+ local state = renderer.get()
+
+ local data = UtilPicker.search_cache(state)
+ if not data then
+ Util.warn("picker.fzf-lua", "No active search")
+ return
+ end
+
+ local contents = data.contents
+ local seen = data.seen
+
+ local items = {}
+ for i, line in ipairs(contents) do
+ table.insert(items, { idx = i, text = line })
+ end
+
+ snacks.picker.pick {
+ title = Util.format_title "",
+ items = items,
+ layout = { preset = "select" },
+ format = function(item)
+ return { { item.text } }
+ end,
+ confirm = function(picker, item)
+ picker:close()
+ if item then
+ Mapping.search(state, seen, item.text)
+ end
+ end,
+ }
+end
+
+return M
diff --git a/lua/pdfview/pickers/telescope.lua b/lua/pdfview/pickers/telescope.lua
new file mode 100644
index 0000000..df3f8ac
--- /dev/null
+++ b/lua/pdfview/pickers/telescope.lua
@@ -0,0 +1,217 @@
+local telescope = require "telescope.builtin"
+local pickers = require "telescope.pickers"
+local finders = require "telescope.finders"
+local conf = require("telescope.config").values
+local previewers = require "telescope.previewers"
+local actions = require "telescope.actions"
+local action_state = require "telescope.actions.state"
+
+local Util = require "pdfview.utils"
+local UtilPicker = require "pdfview.pickers.utils"
+
+local M = {}
+
+-- Custom previewer for Telescope to show the first page
+local pdf_previewer = previewers.new_buffer_previewer {
+ define_preview = function(self, entry)
+ local pdf_path = entry.path
+ local preview_text = require("pdfview").preview_first_page(pdf_path)
+ vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, vim.split(preview_text, "\n"))
+ end,
+}
+
+local Mapping = {}
+
+---@param pdf_bookmarks PDFviewBookmarkSaved[]
+---@param cb function
+---@param entry string
+function Mapping.default_bookmark(pdf_bookmarks, cb, entry)
+ if not entry then
+ return
+ end
+
+ local sel = vim.split(entry, "·")
+
+ local sel_page_num = Util.strip_whitespace(sel[1])
+ local sel_pdf_path = Util.strip_whitespace(sel[2])
+
+ for i, _pdf in pairs(pdf_bookmarks) do
+ if _pdf.text_page == sel_page_num and _pdf.text_path == sel_pdf_path then
+ return cb(pdf_bookmarks[i])
+ end
+ end
+end
+
+---@param pdf_bookmarks PDFviewBookmarkSaved[]
+---@param entry string
+function Mapping.delete_bookmark_entry(pdf_bookmarks, entry)
+ if not entry then
+ return
+ end
+ local sel = vim.split(entry, "·")
+
+ local sel_page_num = Util.strip_whitespace(sel[1])
+ local sel_pdf_path = Util.strip_whitespace(sel[2])
+ local file_saved = require("pdfview.config").defaults.save
+
+ for i, _pdf in pairs(pdf_bookmarks) do
+ if _pdf.text_page == sel_page_num and _pdf.text_path == sel_pdf_path then
+ table.remove(pdf_bookmarks, i)
+
+ Util.save_table_to_file(pdf_bookmarks, file_saved)
+ Util.info(_pdf.text_path .. " removed.")
+ return
+ end
+ end
+end
+
+---@param state PDFviewStateRender
+---@param seen table
+---@param entry string
+function Mapping.search(state, seen, entry)
+ if not entry then
+ return
+ end
+
+ local item = seen[vim.trim(entry)]
+ if not item then
+ return
+ end
+
+ require("pdfview").go_to(item.page, state, true)
+ Util.__add_buf_highlight(item, state)
+end
+
+---@return boolean
+function M.is_available()
+ return (pcall(require, "telescope"))
+end
+
+-- Telescope function with preview and open functionality
+---@param path string
+---@param cb function
+function M.files(path, cb)
+ telescope.find_files {
+ prompt_title = Util.format_title "pdf files",
+ find_command = UtilPicker.find_command(path),
+ previewer = pdf_previewer,
+ attach_mappings = function(_, map)
+ map("i", "", function(prompt_bufnr)
+ ---@diagnostic disable-next-line: redundant-parameter
+ local selected_file = action_state.get_selected_entry(prompt_bufnr)
+ actions.close(prompt_bufnr)
+ local pdf_path = selected_file.path
+ cb(pdf_path)
+ end)
+ return true
+ end,
+ }
+end
+
+---@param path string
+---@param cb function
+function M.bookmark(path, cb)
+ local pdf_bookmarks = Util.get_pdf_bookmarks()
+ if not pdf_bookmarks then
+ return
+ end
+
+ local contents = UtilPicker.bookmark_contents(pdf_bookmarks)
+ if Util.is_blank(contents) then
+ return
+ end
+
+ pickers
+ .new({}, {
+ prompt_title = Util.format_title "bookmarks · [ delete]",
+ finder = finders.new_table {
+ results = contents,
+ entry_maker = function(entry)
+ return {
+ value = entry,
+ display = entry,
+ ordinal = entry,
+ }
+ end,
+ },
+ sorter = conf.generic_sorter {},
+ attach_mappings = function(prompt_bufnr, map)
+ actions.select_default:replace(function()
+ local entry = action_state.get_selected_entry()
+ actions.close(prompt_bufnr)
+ if entry then
+ Mapping.default_bookmark(pdf_bookmarks, cb, entry.value)
+ end
+ end)
+
+ map({ "i", "n" }, "", function()
+ local entry = action_state.get_selected_entry()
+ if not entry then
+ return
+ end
+ Mapping.delete_bookmark_entry(pdf_bookmarks, entry.value)
+
+ -- Refresh list
+ local current_picker = action_state.get_current_picker(prompt_bufnr)
+ local new_contents = UtilPicker.bookmark_contents(pdf_bookmarks)
+ current_picker:refresh(
+ finders.new_table {
+ results = new_contents,
+ entry_maker = function(e)
+ return { value = e, display = e, ordinal = e }
+ end,
+ },
+ { reset_prompt = false }
+ )
+ end)
+
+ return true
+ end,
+ })
+ :find()
+end
+
+function M.search()
+ local renderer = require "pdfview.renderer"
+ local state = renderer.get()
+
+ local data = UtilPicker.search_cache(state)
+ if not data then
+ Util.warn("picker.telescope", "No active search")
+ return
+ end
+
+ local contents = data.contents
+ local seen = data.seen
+
+ pickers
+ .new({}, {
+ prompt_title = Util.format_title "",
+ results_title = string.format("%d %s found", #contents, #contents == 1 and "result" or "results"),
+ finder = finders.new_table {
+ results = contents,
+ entry_maker = function(entry)
+ return {
+ value = entry,
+ display = entry,
+ ordinal = entry,
+ }
+ end,
+ },
+ sorter = conf.generic_sorter {},
+ attach_mappings = function(prompt_bufnr)
+ actions.select_default:replace(function()
+ local entry = action_state.get_selected_entry()
+ actions.close(prompt_bufnr)
+ if entry then
+ Mapping.search(state, seen, entry.value)
+ end
+ end)
+
+ return true
+ end,
+ })
+ :find()
+end
+
+return M
diff --git a/lua/pdfview/pickers/utils.lua b/lua/pdfview/pickers/utils.lua
new file mode 100644
index 0000000..95fd4ce
--- /dev/null
+++ b/lua/pdfview/pickers/utils.lua
@@ -0,0 +1,63 @@
+local Util = require "pdfview.utils"
+
+local M = {}
+
+---@param pdf_bookmarks PDFviewBookmarkSaved[]
+local padding = function(pdf_bookmarks)
+ local _pad = 0
+ for _, x in ipairs(pdf_bookmarks) do
+ if _pad < #x.text_page then
+ _pad = #x.text_page
+ end
+ end
+ return _pad
+end
+
+---@param path string
+---@return string[]
+function M.find_command(path)
+ return { "find", path or ".", "-type", "f", "-name", "*.pdf" }
+end
+
+---@param pdf_bookmarks PDFviewBookmarkSaved[]
+---@return string[]
+function M.bookmark_contents(pdf_bookmarks)
+ if not pdf_bookmarks then
+ return {}
+ end
+ if Util.is_blank(pdf_bookmarks) then
+ Util.warn "No saved PDF bookmarks found. Please create one first."
+ return {}
+ end
+
+ local _pad = padding(pdf_bookmarks)
+
+ local contents = {}
+ for _, _pdf in pairs(pdf_bookmarks) do
+ contents[#contents + 1] = string.format("%-" .. _pad .. "s · %s", _pdf.text_page, _pdf.text_path)
+ end
+ return contents
+end
+
+---@param state PDFviewStateRender
+---@return {contents: table, seen: table}|nil
+function M.search_cache(state)
+ if not state.search or not state.search.cache or not state.search.current_query then
+ return nil
+ end
+
+ local items = state.search.cache[state.search.current_query]
+ local contents = {}
+ local seen = {}
+ for _, x in pairs(items) do
+ contents[#contents + 1] = x.text_line
+ seen[x.text_line] = x
+ end
+
+ return {
+ contents = contents,
+ seen = seen,
+ }
+end
+
+return M
diff --git a/lua/pdfview/renderer.lua b/lua/pdfview/renderer.lua
index ce5b47b..6d1a745 100644
--- a/lua/pdfview/renderer.lua
+++ b/lua/pdfview/renderer.lua
@@ -1,15 +1,28 @@
+local Config = require "pdfview.config"
+local Util = require "pdfview.utils"
+
local M = {}
-- State to keep track of pages
+---@type PDFviewStateRender
local state = {
current_page = 1,
total_pages = 0,
+ pdf_path = "",
+ filetype = "pdfview",
+ page_offset = 0,
+ buf = nil,
+ ns_id = nil,
+ ns_search_id = nil,
pages = {},
}
-- Function to display the current page
function M.display_current_page()
local buf = state.buf
+ if not buf or not vim.api.nvim_buf_is_valid(buf) then
+ return
+ end
-- Set buffer to modifiable before making changes
vim.api.nvim_set_option_value("modifiable", true, { buf = buf })
@@ -31,10 +44,19 @@ end
-- Function to update page information
function M.update_page_info()
+ if Config.defaults.pdf_path then
+ state.pdf_path = Config.defaults.pdf_path
+ end
+
local buf = state.buf
- local page_info = string.format("Page %d/%d", state.current_page, state.total_pages)
+ if not buf or not vim.api.nvim_buf_is_valid(buf) then
+ return
+ end
+
+ local real_page = state.current_page + (state.page_offset or 0)
+ local real_total = state.total_pages + (state.page_offset or 0)
+ local page_info = string.format("Page %d/%d", real_page, real_total)
- -- Set virtual text at the end of the buffer
vim.api.nvim_buf_clear_namespace(buf, state.ns_id, 0, -1)
vim.api.nvim_buf_set_extmark(buf, state.ns_id, 0, 0, {
virt_text = { { page_info, "Comment" } },
@@ -43,46 +65,53 @@ function M.update_page_info()
end
-- Function to split text into pages
-function M.paginate_text(text, lines_per_page)
- local lines = vim.split(text, "\n")
+function M.paginate_text(text)
+ text = text:gsub("\f%s*$", "")
+
+ local raw_pages = vim.split(text, "\f", { plain = true })
local pages = {}
- for i = 1, #lines, lines_per_page do
- local page = vim.list_slice(lines, i, i + lines_per_page - 1)
- table.insert(pages, page)
+ for _, page_text in ipairs(raw_pages) do
+ table.insert(pages, vim.split(page_text, "\n"))
end
return pages
end
-- Function to initialize the buffer and display the first page
-function M.display_text(text)
- -- Default lines per page
- local lines_per_page = 50 -- You can make this configurable
+function M.display_text(text, start_page, pdf_path)
+ if Config.defaults.pdf_path then
+ state.pdf_path = pdf_path
+ end
- -- Split text into pages
- state.pages = M.paginate_text(text, lines_per_page)
+ state.pages = M.paginate_text(text)
state.total_pages = #state.pages
- state.current_page = 1
- -- Create a new buffer or reuse existing one
+ if start_page then
+ state.current_page = start_page
+ else
+ state.current_page = 1
+ end
+
+ state.page_offset = (start_page or 1) - 1
+
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
state.buf = vim.api.nvim_create_buf(false, true)
end
+
local buf = state.buf
- -- Set buffer-local options using nvim_set_option_value
vim.api.nvim_set_option_value("buftype", "nofile", { buf = buf })
vim.api.nvim_set_option_value("bufhidden", "wipe", { buf = buf })
vim.api.nvim_set_option_value("swapfile", false, { buf = buf })
vim.api.nvim_set_option_value("modifiable", false, { buf = buf })
- vim.api.nvim_set_option_value("filetype", "pdfview", { buf = buf })
+ vim.api.nvim_set_option_value("filetype", state.filetype, { buf = buf })
- state.ns_id = vim.api.nvim_create_namespace("PDFview")
+ state.ns_id = vim.api.nvim_create_namespace "PDFview"
- -- Display the current page
M.display_current_page()
- -- Open a new window with the buffer
- vim.api.nvim_set_current_buf(buf)
+ if buf and vim.api.nvim_buf_is_valid(buf) then
+ vim.api.nvim_set_current_buf(state.buf)
+ end
end
-- Function to go to the next page
@@ -91,7 +120,7 @@ function M.next_page()
state.current_page = state.current_page + 1
M.display_current_page()
else
- vim.api.nvim_echo({ { "PDFview: Already at the last page.", "WarningMsg" } }, false, {})
+ Util.warn("renderer", "Already at the last page.")
end
end
@@ -101,8 +130,12 @@ function M.previous_page()
state.current_page = state.current_page - 1
M.display_current_page()
else
- vim.api.nvim_echo({ { "PDFview: Already at the first page.", "WarningMsg" } }, false, {})
+ Util.warn("renderer", "Already at the firts page.")
end
end
+function M.get()
+ return state
+end
+
return M
diff --git a/lua/pdfview/search.lua b/lua/pdfview/search.lua
new file mode 100644
index 0000000..4d521e6
--- /dev/null
+++ b/lua/pdfview/search.lua
@@ -0,0 +1,46 @@
+local Util = require "pdfview.utils"
+
+local parser = require "pdfview.parser"
+local renderer = require "pdfview.renderer"
+
+local M = {}
+
+---@param pdf_path string
+---@param query string
+---@param state? PDFviewStateRender
+---@param opts? {case_sensitive: boolean, pages:table}
+---@return PDFviewMatch[]
+function M.find_matches(pdf_path, query, state, opts)
+ opts = opts or {}
+ state = state or renderer.get()
+
+ local pages = state.pages
+ if Util.is_blank(pages) then
+ local full_text = parser.extract_text(pdf_path)
+ if not full_text then
+ return {}
+ end
+ pages = renderer.paginate_text(full_text)
+ end
+
+ local matches = {}
+ local pattern = opts.case_sensitive and query or query:lower()
+
+ for page_num, lines in ipairs(pages) do
+ for line_num, line in ipairs(lines) do
+ local haystack = opts.case_sensitive and line or line:lower()
+ local col = haystack:find(pattern, 1, true)
+ if col then
+ table.insert(matches, {
+ page = page_num,
+ line = line_num,
+ col = col,
+ text = vim.trim(line),
+ })
+ end
+ end
+ end
+ return matches
+end
+
+return M
diff --git a/lua/pdfview/types.lua b/lua/pdfview/types.lua
new file mode 100644
index 0000000..4e6878b
--- /dev/null
+++ b/lua/pdfview/types.lua
@@ -0,0 +1,67 @@
+---@class PDFviewKeySpec
+---@field key string|string[]
+---@field fun string|function
+---@field desc string
+---@field buf? integer
+
+---@class PDFviewKeymaps
+---@field go_to_page string
+---@field show_page_in_zathura string
+---@field next_page string
+---@field prev_page string
+---@field open_bookmark string
+---@field menu string
+---@field save_bookmark string
+---@field search string
+---@field pick_search string
+---@field next_search_text string
+---@field prev_search_text string
+
+---@class PDFviewPopup
+---@field winhighlight string|nil
+---@field text_search_indicator {sep_front: string|nil, sep_back: string|nil, hl: string|nil, attr: string|nil, icon: string|nil}
+
+---@class PDFviewBookmarkSaved
+---@field last_page integer
+---@field real_page integer
+---@field pdf_path string
+---@field created_at number
+---@field text_path string
+---@field text_page string
+
+---@class PDFviewCfg
+---@field path string
+---@field save string
+---@field picker string
+---@field window PDFviewPopup
+---@field pdf_path string|nil
+---@field keymaps PDFviewKeymaps
+---@field open? {cb?:function}
+---@field ui? {cb?:function, menu?: table}
+---@field group? string
+
+---@class PDFviewMatch
+---@field page integer
+---@field line integer
+---@field col integer
+---@field text string
+---@field text_line string
+---@field filename string
+
+---@class PDFviewMatchQuery
+---@field current_query string
+---@field cache table
+
+---@class PDFviewStateRender
+---@field current_page integer
+---@field total_pages integer
+---@field pdf_path string
+---@field filetype string
+---@field pages table
+---@field buf integer|nil
+---@field ns_id integer|nil
+---@field ns_search_id integer|nil
+---@field page_offset integer
+---@field win? integer
+---@field win_status_search_indicator? integer
+---@field search? PDFviewMatchQuery
diff --git a/lua/pdfview/ui.lua b/lua/pdfview/ui.lua
new file mode 100644
index 0000000..ea85906
--- /dev/null
+++ b/lua/pdfview/ui.lua
@@ -0,0 +1,305 @@
+local Util = require "pdfview.utils"
+
+local M = {}
+
+local _ui = {
+ menu_filetype = "pdfview_menu",
+ ns = "PDFviewU",
+}
+
+---@alias WinCfg { buf: integer, enter: boolean, wincfg: vim.api.keyset.win_config }
+---@alias WinSizeCfg { row: integer, col: integer, height: integer, width: integer, title: string, title_pos: string, buf?: integer}
+
+---@return {width: integer, height: integer}
+local function get_editor_size()
+ local ui = vim.api.nvim_list_uis()[1]
+ return {
+ width = ui.width,
+ height = ui.height,
+ }
+end
+
+---@param win_opts WinCfg
+---@param lines? table
+---@return integer, integer
+local function new_open(win_opts, lines)
+ lines = lines or {}
+
+ local opts = win_opts
+
+ if not opts.buf then
+ opts.buf = vim.api.nvim_create_buf(false, true)
+ end
+
+ vim.api.nvim_buf_set_lines(opts.buf, 0, -1, false, lines)
+
+ local win = vim.api.nvim_open_win(opts.buf, opts.enter, opts.wincfg)
+ return opts.buf, win
+end
+
+local view = {}
+
+---@param opts {buf: integer, win:integer, hval: table}
+function view.setup_ui_mappings(opts)
+ local keymaps = require "pdfview.keymaps"
+ local pdfview = require "pdfview"
+
+ ---@type PDFviewKeySpec[]
+ local keys = {}
+
+ keys = {
+ -- Enter
+ {
+ key = "",
+ fun = function()
+ vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true)
+ local cur_line = vim.api.nvim_win_get_cursor(0)[1]
+ Util.close_win { win = { opts.win }, buf = { opts.buf } }
+
+ vim.schedule(function()
+ if opts.hval[cur_line].method == "delete_item_bookmark" then
+ require("pdfview.pickers.default").delete_item_bookmark()
+ elseif pdfview[opts.hval[cur_line].method] then
+ pdfview[opts.hval[cur_line].method]()
+ end
+ end)
+ end,
+ desc = "select item",
+ buf = opts.buf,
+ },
+ -- Quit
+ {
+ key = { "q", "", "" },
+ fun = function()
+ vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true)
+ Util.close_win { win = { opts.win }, buf = { opts.buf } }
+ end,
+ desc = "quit",
+ buf = opts.buf,
+ },
+
+ -- Navigate
+ {
+ key = { "k", "", "" },
+ fun = function()
+ vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true)
+ vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("k", true, false, true), "n", true)
+ end,
+ desc = "quit",
+ buf = opts.buf,
+ },
+ {
+ key = { "j", "", "" },
+ fun = function()
+ vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true)
+ vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("j", true, false, true), "n", true)
+ end,
+ desc = "quit",
+ buf = opts.buf,
+ },
+ }
+
+ for _, h in pairs(opts.hval) do
+ keys[#keys + 1] = {
+ key = h.shortcut,
+ fun = function()
+ vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true)
+ Util.close_win { win = { opts.win }, buf = { opts.buf } }
+ vim.schedule(function()
+ if h.method == "delete_item_bookmark" then
+ require("pdfview.pickers.default").delete_item_bookmark()
+ elseif pdfview[h.method] then
+ pdfview[h.method]()
+ end
+ end)
+ end,
+ desc = "select item",
+ buf = opts.buf,
+ }
+ end
+
+ keymaps.append_to(keys)
+end
+
+---@param buf integer
+---@param lines string[]
+---@param padding_line integer
+local function apply_higlights(buf, lines, padding_line)
+ local ns = vim.api.nvim_create_namespace(_ui.ns)
+ Util.del_namespace(buf, ns)
+
+ for lnum, line in ipairs(lines) do
+ if not line or line == "" then
+ goto continue
+ end
+
+ local row = lnum - 1
+
+ local _, bullet_end = line:find("●", 1, true)
+ if not bullet_end then
+ goto continue
+ end
+
+ local item_field_width = padding_line
+ local item_start = bullet_end + 2
+ local item_end = item_start + item_field_width
+ local shortcut_start = item_end + 1
+
+ -- Highlight name item
+ Util.set_extmark(buf, ns, row, item_start, {
+ end_col = item_end,
+ hl_group = "Function",
+ priority = 8,
+ })
+
+ -- Highlight shortcut
+ Util.set_extmark(buf, ns, row, shortcut_start, {
+ end_col = #line,
+ hl_group = "Boolean",
+ priority = 8,
+ })
+
+ ::continue::
+ end
+end
+
+---@param cfg PDFviewCfg
+function view.menu(cfg)
+ local win_buf = vim.api.nvim_create_buf(false, true)
+
+ local lines = {
+ "Select file pdf",
+ "Select bookmark",
+ }
+
+ if vim.bo.filetype ~= "pdfview" then
+ lines[#lines + 1] = "Open from last bookmark"
+ lines[#lines + 1] = "Open last"
+ end
+
+ if vim.bo.filetype == "pdfview" then
+ lines[#lines + 1] = "Open in zathura"
+ lines[#lines + 1] = "Go to"
+ lines[#lines + 1] = "Text search"
+ lines[#lines + 1] = "Select text search"
+
+ if cfg.picker and cfg.picker == "default" then
+ lines[#lines + 1] = "Delete item bookmark"
+ end
+ end
+
+ ---@type table
+ local hval = {}
+ local display_lines = {}
+
+ local seen = {}
+ local resolve_shortcut = function(item)
+ for i = 1, #item do
+ local shortcut = item:sub(i, i)
+ shortcut = shortcut:lower()
+ if not seen[shortcut] then
+ seen[shortcut] = true
+ return shortcut
+ end
+ end
+ end
+
+ local padding_line = 0
+ for _, str_line in pairs(lines) do
+ local str_w = vim.fn.strdisplaywidth(str_line)
+ if padding_line < str_w then
+ padding_line = str_w + 2
+ end
+ end
+
+ table.sort(lines, function(a, b)
+ return a:lower() < b:lower()
+ end)
+
+ local padding_display_lines = 0
+ for i, item in ipairs(lines) do
+ local shortcut = resolve_shortcut(item)
+ local text_line = string.format(" %s %-" .. padding_line .. "s %s", "●", item, shortcut)
+ table.insert(display_lines, text_line)
+ hval[i] = {
+ idx = i,
+ item = item,
+ shortcut = shortcut,
+ method = item:gsub(" ", "_"):lower(),
+ }
+ local len_text_line = vim.fn.strdisplaywidth(text_line)
+ if padding_display_lines < len_text_line then
+ padding_display_lines = len_text_line
+ end
+ end
+
+ local editor_size = get_editor_size()
+ local width = math.floor(editor_size.width * 20 / 100)
+ width = math.min(width, padding_display_lines + 4)
+ local height = #display_lines + 1
+
+ ---@param height_editor integer
+ ---@param width_editor integer
+ local function get_center_col_row(height_editor, width_editor)
+ local row = math.ceil((editor_size.height - height_editor) / 2) - 5
+ local col = math.ceil((editor_size.width - width_editor) / 2)
+ return col, row
+ end
+
+ local col, row = get_center_col_row(height, width)
+
+ ---@type WinCfg
+ local wincfg = {
+ buf = win_buf,
+ enter = true,
+ wincfg = {
+ relative = "editor",
+ width = width,
+ height = height,
+ row = row,
+ col = col,
+ style = "minimal",
+ border = "rounded",
+ title = Util.format_title "menu",
+ title_pos = "center",
+ footer = " / quit ",
+ footer_pos = "center",
+ },
+ }
+ local main_buf, main_win = new_open(wincfg, display_lines)
+
+ vim.bo[main_buf].filetype = _ui.menu_filetype
+ cfg.ui.menu = hval
+
+ vim.api.nvim_set_option_value("cursorline", true, { win = main_win, scope = "local" })
+ vim.api.nvim_set_option_value(
+ "winhighlight",
+ cfg.window.winhighlight and cfg.window.winhighlight
+ or "Normal:Error,"
+ .. "NormalFloat:NormalFloat,"
+ .. "FloatBorder:FloatBorder,"
+ .. "FloatTitle:FloatTitle,"
+ .. "FloatFooter:FloatBorder,",
+ { win = main_win, scope = "local" }
+ )
+
+ vim.api.nvim_set_option_value("modifiable", false, { buf = main_buf })
+ vim.api.nvim_set_option_value("readonly", true, { buf = main_buf })
+ vim.api.nvim_set_option_value("buftype", "nofile", { buf = main_buf })
+
+ apply_higlights(main_buf, display_lines, padding_line)
+
+ local opts = { buf = main_buf, win = main_win, hval = hval }
+ view.setup_ui_mappings(opts)
+end
+
+---@param cfg PDFviewCfg
+function M.call(call_name, cfg)
+ if not view[call_name] then
+ return
+ end
+ cfg.ui = _ui
+ view[call_name](cfg)
+end
+
+return M
diff --git a/lua/pdfview/utils.lua b/lua/pdfview/utils.lua
index 79b1b88..2343ddb 100644
--- a/lua/pdfview/utils.lua
+++ b/lua/pdfview/utils.lua
@@ -1,3 +1,5 @@
+local Plenary_path = require "plenary.path"
+
local M = {}
function M.is_iterm2()
@@ -8,4 +10,395 @@ function M.is_kitty()
return vim.env.TERM == "xterm-kitty"
end
+---@param module_or_message string
+---@param message? string
+local function notify(hl, module_or_message, message)
+ local module
+
+ if message == nil then
+ message = module_or_message
+ else
+ module = module_or_message
+ end
+
+ local prefix = module and ("PDFview." .. module) or "PDFview"
+
+ vim.api.nvim_echo({
+ { ("(%s) "):format(prefix), hl },
+ { message },
+ }, true, {})
+end
+
+---@param module_or_message string
+---@param message? string
+function M.info(module_or_message, message)
+ notify("Directory", module_or_message, message)
+end
+
+---@param module_or_message string
+---@param message? string
+function M.warn(module_or_message, message)
+ notify("WarningMsg", module_or_message, message)
+end
+
+---@param module_or_message string
+---@param message? string
+function M.error(module_or_message, message)
+ notify("ErrorMsg", module_or_message, message)
+end
+
+---@param msg? string
+function M.not_implemented_yet(msg)
+ if msg == nil then
+ msg = ""
+ end
+ if #msg > 0 then
+ msg = "Not impelemented, -> " .. msg
+ else
+ msg = "Not impelemented yet"
+ end
+ M.warn(msg)
+end
+
+---@return boolean
+function M.is_blank(s)
+ return (
+ s == nil
+ or s == vim.NIL
+ or (type(s) == "string" and string.match(s, "%S") == nil)
+ or (type(s) == "table" and next(s) == nil)
+ )
+end
+
+---@param filename string
+---@return boolean | string
+function M.exists(filename)
+ local stat
+ if filename then
+ stat = vim.loop.fs_stat(filename)
+ end
+
+ return stat and stat.type or false
+end
+
+---@param filename string
+---@return boolean
+function M.is_dir(filename)
+ return M.exists(filename) == "directory"
+end
+
+---@return boolean
+function M.is_file(filename)
+ return M.exists(filename) == "file"
+end
+
+function M.create_file(path)
+ local p = Plenary_path.new(path)
+ if not p:exists() then
+ p:touch()
+ end
+end
+
+function M.create_dir(path)
+ local p = Plenary_path.new(path)
+ if not p:exists() then
+ p:mkdir()
+ end
+end
+
+function M.system_command(cmds)
+ vim.system(cmds, { detach = true }, function(res)
+ if res.code ~= 0 then
+ vim.schedule(function()
+ ---@diagnostic disable-next-line: undefined-field
+ M.error("utils", "failed to open Zathura:" .. (res.stderr or ""))
+ end)
+ end
+ end)
+end
+
+---@param name string
+---@param opts? {group: string}
+function M.create_augroup_name(name, opts)
+ opts = opts or { group = "PDFview" }
+ return vim.api.nvim_create_augroup(opts.group .. name, { clear = true })
+end
+
+---@param augroup_name string
+function M.clear_autocmd_group(augroup_name)
+ pcall(vim.api.nvim_clear_autocmds, { group = augroup_name })
+ pcall(vim.api.nvim_del_augroup_by_name, augroup_name)
+end
+
+---@param title string
+---@param prefix? string
+function M.format_title(title, prefix)
+ return " " .. (prefix or "PDFview:") .. " " .. title .. " "
+end
+
+local function delete_bufnr(buf)
+ vim.api.nvim_buf_delete(buf, { force = true })
+end
+
+---@param opts {buf?: integer|integer[], win?: integer|integer[]}
+function M.close_win(opts)
+ opts = opts or {}
+
+ ---@param val integer|integer[]|nil
+ ---@return integer[]
+ local function to_list(val)
+ if val == nil then
+ return {}
+ elseif type(val) == "table" then
+ return val
+ else
+ return { val }
+ end
+ end
+
+ for _, w in ipairs(to_list(opts.win)) do
+ if vim.api.nvim_win_is_valid(w) then
+ vim.api.nvim_win_close(w, true)
+ end
+ end
+
+ for _, b in ipairs(to_list(opts.buf)) do
+ if vim.api.nvim_buf_is_valid(b) then
+ delete_bufnr(b)
+ end
+ end
+end
+
+---@param contents PDFviewBookmarkSaved[]
+---@param filename string
+function M.save_table_to_file(contents, filename)
+ local file = io.open(filename, "w")
+ if file then
+ file:write "return "
+ file:write(tostring(vim.inspect(contents)))
+ file:close()
+ else
+ M.warn("utils", "Failed to save data table to file")
+ end
+end
+
+---@param str string
+---@return string
+local rstrip_whitespace = function(str)
+ str = string.gsub(str, "%s+$", "")
+ return str
+end
+
+---@param str string
+---@param limit? string|nil
+---@return string
+local lstrip_whitespace = function(str, limit)
+ if limit ~= nil then
+ local num_found = 0
+ while num_found < limit do
+ str = string.gsub(str, "^%s", "")
+ num_found = num_found + 1
+ end
+ else
+ str = string.gsub(str, "^%s+", "")
+ end
+ return str
+end
+
+---@param str string
+---@return string
+function M.strip_whitespace(str)
+ if str then
+ return rstrip_whitespace(lstrip_whitespace(str))
+ end
+ return ""
+end
+
+local get_hl = function(group)
+ return vim.api.nvim_get_hl(0, { name = group })
+end
+
+---@return PDFviewBookmarkSaved[]|nil
+function M.get_pdf_bookmarks()
+ local Config = require "pdfview.config"
+ local file_saved = Config.defaults.save
+ if not M.is_file(file_saved) then
+ M.create_file(file_saved)
+ end
+ return dofile(file_saved) or {}
+end
+
+---@param opts {buffer_line: integer, target_line: integer, total_text_search: integer, idx_text_search: integer}
+---@param state PDFviewStateRender
+local function __add_text_search_indicator(opts, state)
+ local cfg = require("pdfview.config").defaults
+
+ -- stylua: ignore
+ local sepFront = (cfg.window.text_search_indicator and cfg.window.text_search_indicator.sep_front) and cfg.window.text_search_indicator.sep_front or ""
+ -- stylua: ignore
+ local sepBack = (cfg.window.text_search_indicator and cfg.window.text_search_indicator.sep_back) and cfg.window.text_search_indicator.sep_back or ""
+ local hl_fg_group = (cfg.window.text_search_indicator and cfg.window.text_search_indicator.hl) or "WarningMsg"
+ local hl_fg_attr = (cfg.window.text_search_indicator and cfg.window.text_search_indicator.attr) or "fg"
+ -- stylua: ignore
+ local icon = (cfg.window.text_search_indicator and cfg.window.text_search_indicator.icon) and cfg.window.text_search_indicator.icon or " "
+ local text = string.format(
+ "%d/%d %s found | query: %s",
+ opts.idx_text_search or 0,
+ opts.total_text_search or 0,
+ (opts.total_text_search == 1 or opts.total_text_search == 0) and "result" or "results",
+ state.search.current_query
+ )
+
+ local hl_bg = get_hl(hl_fg_group)[hl_fg_attr]
+ local hl_fg = get_hl("Normal")["bg"]
+
+ vim.api.nvim_set_hl(0, "PDFviewStatusSearchSep", { fg = hl_bg, bg = hl_fg })
+ vim.api.nvim_set_hl(0, "PDFviewStatusSearchIcon", { fg = hl_fg, bg = hl_bg })
+ vim.api.nvim_set_hl(0, "PDFviewStatusSearchNormal", { fg = hl_fg, bg = hl_bg, bold = true })
+
+ local segments = {
+ { text = sepFront, hl = "PDFviewStatusSearchSep" },
+ { text = icon, hl = "PDFviewStatusSearchIcon" },
+ { text = " " .. text, hl = nil },
+ { text = sepBack, hl = "PDFviewStatusSearchSep" },
+ }
+
+ local full_line = ""
+ for _, seg in ipairs(segments) do
+ full_line = full_line .. seg.text
+ end
+
+ -- Close before respawn the indicator
+ if state.win_status_search_indicator and vim.api.nvim_win_is_valid(state.win_status_search_indicator) then
+ vim.api.nvim_win_close(state.win_status_search_indicator, true)
+ end
+
+ -- no need this?
+ -- M.del_namespace(state.buf, state.ns_search_id)
+ -- state.ns_search_id = vim.api.nvim_create_namespace "pdfview_status_search_indicator"
+
+ local buf = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_lines(buf, 0, -1, false, { full_line })
+
+ local ns = vim.api.nvim_create_namespace "pdfview_status_search_indicator"
+
+ local current_col = 0
+ for _, seg in ipairs(segments) do
+ if seg.hl then
+ M.set_extmark(buf, ns, 0, current_col, {
+ end_row = 0,
+ end_col = current_col + #seg.text,
+ hl_group = seg.hl,
+ })
+ end
+ current_col = current_col + #seg.text
+ end
+
+ local winnr = vim.api.nvim_get_current_win()
+
+ local height = 1
+ local width = vim.fn.strdisplaywidth(full_line)
+
+ local win_width = vim.api.nvim_win_get_width(winnr)
+
+ local padding = 2
+
+ local row = padding
+ local col = math.floor((win_width - width) / 2)
+
+ local win = vim.api.nvim_open_win(buf, false, {
+ relative = "win",
+ win = winnr,
+ row = row,
+ col = col,
+ width = width,
+ height = height,
+ style = "minimal",
+ focusable = false,
+ zindex = 8,
+ })
+
+ vim.api.nvim_set_option_value("modifiable", false, { buf = state.buf })
+ vim.api.nvim_set_option_value("bufhidden", "wipe", { buf = state.buf })
+
+ local winhl = "Normal:PDFviewStatusSearchNormal,FloatBorder:Normal,"
+ vim.api.nvim_set_option_value("winhighlight", winhl, { win = win, scope = "local" })
+
+ state.win_status_search_indicator = win
+end
+
+---@param item PDFviewMatch
+---@param state PDFviewStateRender
+---@param idx_text_search? integer
+---@param total_search_text? integer
+function M.__add_buf_highlight(item, state, idx_text_search, total_search_text)
+ vim.schedule(function()
+ if state.win and vim.api.nvim_win_is_valid(state.win) then
+ vim.api.nvim_set_current_win(state.win)
+
+ local bufline_count = vim.api.nvim_buf_line_count(state.buf)
+ local target_line = math.min(item.line, bufline_count)
+ local target_col = math.max((item.col or 1) - 1, 0)
+
+ vim.api.nvim_win_set_cursor(state.win, { target_line, target_col })
+
+ local line_text = vim.api.nvim_buf_get_lines(state.buf, target_line - 1, target_line, false)[1] or ""
+ local end_col = #line_text
+
+ if idx_text_search and total_search_text then
+ local __opts = {
+ buffer_line = bufline_count,
+ target_line = target_line,
+ idx_text_search = idx_text_search,
+ total_text_search = total_search_text,
+ }
+ __add_text_search_indicator(__opts, state)
+ end
+
+ M.del_namespace(state.buf, state.ns_search_id)
+ local mark_id = M.set_extmark(state.buf, state.ns_search_id, target_line - 1, target_col, {
+ end_row = target_line - 1,
+ end_col = end_col,
+ hl_group = "Search",
+ })
+
+ if mark_id then
+ vim.defer_fn(function()
+ M.del_extmark(state.buf, state.ns_search_id, mark_id)
+ end, 2000)
+ end
+ end
+ end)
+end
+
+function M.set_extmark(bufnr, namespace_name, line, col, opts)
+ if vim.api.nvim_buf_is_valid(bufnr) then
+ local ok, id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace_name, line, col, opts)
+
+ if not ok then
+ M.error "failed to create extmark annotation."
+ return nil
+ end
+
+ return id
+ end
+end
+
+---@param bufnr integer
+---@param ns integer
+---@param id integer
+function M.del_extmark(bufnr, ns, id)
+ if vim.api.nvim_buf_is_valid(bufnr) then
+ return pcall(vim.api.nvim_buf_del_extmark, bufnr, ns, id)
+ end
+end
+
+---@param bufnr integer
+---@param ns integer
+function M.del_namespace(bufnr, ns)
+ if vim.api.nvim_buf_is_valid(bufnr) then
+ vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
+ end
+end
+
return M
diff --git a/selena.toml b/selena.toml
new file mode 100644
index 0000000..161f9f0
--- /dev/null
+++ b/selena.toml
@@ -0,0 +1,6 @@
+std = "lua51+vim"
+
+[rules]
+global_usage = "allow"
+multiple_statements = "allow"
+incorrect_standard_library_use = "allow"
diff --git a/stylua.toml b/stylua.toml
new file mode 100644
index 0000000..4a648e5
--- /dev/null
+++ b/stylua.toml
@@ -0,0 +1,6 @@
+indent_type = "Spaces"
+quote_style = "AutoPreferDouble"
+no_call_parentheses = true
+column_width = 120
+indent_width = 2
+syntax = "Lua52"