From c8371812890731319f7f3b5cf681ac63b5ccf023 Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Thu, 9 Jul 2026 06:55:53 +0700 Subject: [PATCH 01/19] feat: make feature configurable via opts --- README.md | 12 +-- lua/pdfview/config.lua | 33 ++++++++ lua/pdfview/init.lua | 95 +++++++++++++---------- lua/pdfview/parser.lua | 45 +++++------ lua/pdfview/pickers/default.lua | 25 ++++++ lua/pdfview/pickers/fzf-lua.lua | 75 ++++++++++++++++++ lua/pdfview/pickers/init.lua | 50 ++++++++++++ lua/pdfview/pickers/telescope.lua | 39 ++++++++++ lua/pdfview/pickers/utils.lua | 9 +++ lua/pdfview/renderer.lua | 125 +++++++++++++++++++++++++----- lua/pdfview/types.lua | 13 ++++ lua/pdfview/utils.lua | 122 +++++++++++++++++++++++++++++ selena.toml | 6 ++ stylua.toml | 6 ++ 14 files changed, 563 insertions(+), 92 deletions(-) create mode 100644 lua/pdfview/config.lua create mode 100644 lua/pdfview/pickers/default.lua create mode 100644 lua/pdfview/pickers/fzf-lua.lua create mode 100644 lua/pdfview/pickers/init.lua create mode 100644 lua/pdfview/pickers/telescope.lua create mode 100644 lua/pdfview/pickers/utils.lua create mode 100644 lua/pdfview/types.lua create mode 100644 selena.toml create mode 100644 stylua.toml diff --git a/README.md b/README.md index 885ddac..93cc14b 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ map("n", "kk", ":lua require('pdfview.renderer').previous_page()kk", ":lua require('pdfview.renderer').previous_page()", 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/parser.lua b/lua/pdfview/parser.lua index 7fe69a5..f4c3587 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 "PDFview: 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("PDFview: 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..841f026 --- /dev/null +++ b/lua/pdfview/pickers/default.lua @@ -0,0 +1,25 @@ +local UtilPicker = require "pdfview.pickers.utils" + +local M = {} + +local function get_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.select_file_pdf(path, cb) + local files = get_files(path) + vim.ui.select(files, { prompt = "PDFview" }, function(file) + if not file then + return + end + + local pdf_path = vim.fs.normalize(file) + cb(pdf_path) + 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..4b71bcf --- /dev/null +++ b/lua/pdfview/pickers/fzf-lua.lua @@ -0,0 +1,75 @@ +local Util = require "pdfview.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 + +---@param title string +local function format_title(title) + return " " .. title .. " " +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 path string +---@param cb function +function M.select_file_pdf(path, cb) + setup_fzflua() + + if not loaded then + return + end + + FzfLua.files { + cwd = path, + no_header = true, + no_header_i = true, -- hide interactive header? + fzf_opts = { ["--header"] = [[^x:delete ^r:rename]] }, + winopts = { title = format_title "PDFview", preview = { hidden = true } }, + actions = { + ["default"] = Mapping.default(path, cb), + }, + } +end + +return M diff --git a/lua/pdfview/pickers/init.lua b/lua/pdfview/pickers/init.lua new file mode 100644 index 0000000..079d12e --- /dev/null +++ b/lua/pdfview/pickers/init.lua @@ -0,0 +1,50 @@ +local M = {} + +local Util = require "pdfview.utils" + +local function default_picker() + return "default" +end + +local silent_warn_notify = false + +---@param picker_name string? +local function get_picker(picker_name) + picker_name = picker_name or "" + + if Util.is_blank(picker_name) or silent_warn_notify 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 + Util.warn( + string.format( + "The picker `%s` has not been implemented yet.\nFalling back to the default `vim.ui.select`.", + picker_name + ) + ) + end + + silent_warn_notify = true + + return get_picker "default" + end + + return picker +end + +---@param picker_name string +---@param path string +---@param cb function +function M.select_file(picker_name, path, cb) + local p = get_picker(picker_name) + + if p then + p.select_file_pdf(path, cb) + end +end + +return M diff --git a/lua/pdfview/pickers/telescope.lua b/lua/pdfview/pickers/telescope.lua new file mode 100644 index 0000000..18a76f6 --- /dev/null +++ b/lua/pdfview/pickers/telescope.lua @@ -0,0 +1,39 @@ +local telescope = require "telescope.builtin" +local previewers = require "telescope.previewers" +local actions = require "telescope.actions" +local action_state = require "telescope.actions.state" +local util = 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 = 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 +---@param path string +---@param cb function +function M.telescope_open(path, cb) + telescope.find_files { + prompt_title = "PDFview", + find_command = util.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 + +return M diff --git a/lua/pdfview/pickers/utils.lua b/lua/pdfview/pickers/utils.lua new file mode 100644 index 0000000..934280d --- /dev/null +++ b/lua/pdfview/pickers/utils.lua @@ -0,0 +1,9 @@ +local M = {} + +---@param path string +---@return string[] +function M.find_command(path) + return { "find", path or ".", "-type", "f", "-name", "*.pdf" } +end + +return M diff --git a/lua/pdfview/renderer.lua b/lua/pdfview/renderer.lua index ce5b47b..2ed5f00 100644 --- a/lua/pdfview/renderer.lua +++ b/lua/pdfview/renderer.lua @@ -1,12 +1,94 @@ +local Config = require "pdfview.config" +local Util = require "pdfview.utils" + local M = {} -- State to keep track of pages local state = { current_page = 1, total_pages = 0, + pdf_path = "", + filetype = "pdfview", pages = {}, } +---@param page_num number|nil +local function __go_to(page_num) + if page_num then + state.current_page = page_num + M.display_current_page() + 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 + return + end + + state.current_page = num + + Util.info(string.format("Go to page: %d", state.current_page)) + M.display_current_page() + end) +end + +---@param page_num number|nil +local function __open_in_zathura(page_num) + if Config.defaults.pdf_path then + state.pdf_path = Config.defaults.pdf_path + end + 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_cmd(zathura_cmd) +end + +local function setup_pdfview_ft_mappings(ctx) + local bufnr = ctx.buf + + vim.keymap.set("n", Config.defaults.keymaps.go_to_page, __go_to, { + buf = bufnr, + desc = "Go to page", + }) + + vim.keymap.set("n", Config.defaults.keymaps.show_page_in_zathura, __open_in_zathura, { + buf = bufnr, + desc = "Show page in zathura", + }) + + vim.keymap.set("n", Config.defaults.keymaps.next_page, M.next_page, { + buf = bufnr, + desc = "next page", + }) + + vim.keymap.set("n", Config.defaults.keymaps.prev_page, M.previous_page, { + buf = bufnr, + desc = "previous page", + }) +end + +function M.setup_filetype_mappings(group) + vim.api.nvim_create_autocmd("FileType", { + group = group, + pattern = state.filetype, + desc = "CodeCompanion filetype mappings", + callback = setup_pdfview_ft_mappings, + }) +end + -- Function to display the current page function M.display_current_page() local buf = state.buf @@ -32,9 +114,10 @@ end -- Function to update page information function M.update_page_info() local buf = state.buf - local page_info = string.format("Page %d/%d", state.current_page, state.total_pages) + 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,45 +126,41 @@ 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) + state.pdf_path = pdf_path - -- 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 + 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) end @@ -91,7 +170,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 "PDFview: Already at the last page." end end @@ -101,8 +180,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 "PDFview: Already at the first page." end end +function M.get() + return state +end + return M diff --git a/lua/pdfview/types.lua b/lua/pdfview/types.lua new file mode 100644 index 0000000..80c564e --- /dev/null +++ b/lua/pdfview/types.lua @@ -0,0 +1,13 @@ + +---@class PDFviewKeymaps +---@field go_to_page string +---@field show_page_in_zathura string +---@field next_page string +---@field prev_page string + +---@class PDFviewCfg +---@field path string +---@field picker string +---@field pdf_path string|nil +---@field keymaps PDFviewKeymaps +---@field open? {cb?:function} diff --git a/lua/pdfview/utils.lua b/lua/pdfview/utils.lua index 79b1b88..89ef553 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,124 @@ 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_cmd(cmds) + vim.system(cmds, { detach = true }, function(res) + if res.code ~= 0 then + vim.schedule(function() + ---@diagnostic disable-next-line: undefined-field + M.error("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 + 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" From e0921e8dad2d41f3e7c02aecaad681880a43a4ba Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Thu, 9 Jul 2026 13:52:05 +0700 Subject: [PATCH 02/19] docs(readme): update README with new configuration options and keymaps - Update README with new configuration options - Clarify `next_page`/`prev_page` keymaps are already set via opts.keymaps - Add Zathura installation instructions - Add example for `open.cb` callback (e.g. integrating with codecompanion.nvim) - Add example for statusline integration (e.g. heirline.nvim) - Remove `lines_per_page` --- README.md | 109 +++++++++++++++++++++++++++++++++------ lua/pdfview/renderer.lua | 11 ++-- 2 files changed, 100 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 93cc14b..7f12575 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,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,14 +39,38 @@ To install `PDFview.nvim` using **LazyVim**, add the following configuration to ```lua { "basola21/PDFview", - lazy = false, - dependencies = { "nvim-telescope/telescope.nvim" } -} + dependencies = { + "nvim-telescope/telescope.nvim" + }, + opts = { + path = "/path/to/pdf_folder", + picker = "default", -- fzf-lua, telescope, default (using vim.ui.select) + open = { + cb = nil, + }, + keymaps = { + go_to_page = "qf", + show_page_in_zathura = "qd", + next_page = "", + prev_page = "", + }, + }, + keys = { + { + "fp", + function() + require("pdfview").select_file_pdf() + end, + desc = "Select pdf file", + }, + }, +}, ``` ### 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 -- Navigate to the next page in the PDF @@ -59,14 +87,14 @@ 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. @@ -75,25 +103,74 @@ 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/lua/pdfview/renderer.lua b/lua/pdfview/renderer.lua index 2ed5f00..a743fb4 100644 --- a/lua/pdfview/renderer.lua +++ b/lua/pdfview/renderer.lua @@ -41,9 +41,6 @@ end ---@param page_num number|nil local function __open_in_zathura(page_num) - if Config.defaults.pdf_path then - state.pdf_path = Config.defaults.pdf_path - end if not page_num then page_num = state.current_page end @@ -113,6 +110,10 @@ 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 real_page = state.current_page + (state.page_offset or 0) local real_total = state.total_pages + (state.page_offset or 0) @@ -139,7 +140,9 @@ end -- Function to initialize the buffer and display the first page function M.display_text(text, start_page, pdf_path) - state.pdf_path = pdf_path + if Config.defaults.pdf_path then + state.pdf_path = pdf_path + end state.pages = M.paginate_text(text) state.total_pages = #state.pages From 9f0aa53d63d6f16b2d8af4f467f72e758a5b8860 Mon Sep 17 00:00:00 2001 From: RAULLLLL <151346058+MadKuntilanak@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:25:19 +0700 Subject: [PATCH 03/19] add preview demo --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7f12575..7cc92d6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,10 @@ - **Customizable Pagination**: Set how many lines per page should be displayed. - **Virtual Text Display**: See page numbers displayed in the buffer. -![Preview](https://i.imgur.com/ClDZhnc.gif) + +260709-14-22-34 + + --- From 0960145fccf90db9e284b51cad721a55360d3df5 Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Thu, 9 Jul 2026 14:38:22 +0700 Subject: [PATCH 04/19] fix(readme): typo --- README.md | 9 +++++---- lua/pdfview/config.lua | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7cc92d6..d6ba1a8 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ To install `PDFview.nvim` using **LazyVim**, add the following configuration to ```lua { - "basola21/PDFview", + "basola21/PDFview", -- (or use this fork: "MadKuntilanak/PDFview") + event = "VeryLazy", dependencies = { "nvim-telescope/telescope.nvim" }, @@ -52,8 +53,8 @@ To install `PDFview.nvim` using **LazyVim**, add the following configuration to cb = nil, }, keymaps = { - go_to_page = "qf", - show_page_in_zathura = "qd", + go_to_page = "gf", + show_page_in_zathura = "x", next_page = "", prev_page = "", }, @@ -72,7 +73,7 @@ To install `PDFview.nvim` using **LazyVim**, add the following configuration to ### Mappings -The next_page and prev_page keymaps are already set up for you in the `opts.keymaps table above`. +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 diff --git a/lua/pdfview/config.lua b/lua/pdfview/config.lua index 627b5aa..c2f1317 100644 --- a/lua/pdfview/config.lua +++ b/lua/pdfview/config.lua @@ -9,7 +9,7 @@ M.defaults = { }, keymaps = { go_to_page = "gf", - show_page_in_zathura = "O", + show_page_in_zathura = "x", next_page = "", prev_page = "", }, From 9b334ebd0efd92cc41055e18e98887d0ca440445 Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Fri, 10 Jul 2026 02:04:58 +0700 Subject: [PATCH 05/19] feat: implement bookmark and improve keymap configs --- README.md | 43 +++-- TODO.org | 5 + lua/pdfview/config.lua | 7 + lua/pdfview/init.lua | 152 +++++++++++++++-- lua/pdfview/keymaps.lua | 130 +++++++++++++++ lua/pdfview/pickers/default.lua | 74 ++++++++- lua/pdfview/pickers/fzf-lua.lua | 95 ++++++++++- lua/pdfview/pickers/init.lua | 6 +- lua/pdfview/pickers/telescope.lua | 16 +- lua/pdfview/pickers/utils.lua | 33 ++++ lua/pdfview/renderer.lua | 98 +++-------- lua/pdfview/types.lua | 28 ++++ lua/pdfview/ui.lua | 268 ++++++++++++++++++++++++++++++ lua/pdfview/utils.lua | 106 +++++++++++- 14 files changed, 939 insertions(+), 122 deletions(-) create mode 100644 TODO.org create mode 100644 lua/pdfview/keymaps.lua create mode 100644 lua/pdfview/ui.lua diff --git a/README.md b/README.md index d6ba1a8..861bf72 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,12 @@ ## Features -- **Open PDF Files**: Use Telescope to quickly search and open PDF files. +- **Open PDF Files**: Quickly search and open PDF files using your preferred picker; `telescope.nvim`, `fzf-lua`, or the built-in default (`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. 260709-14-22-34 @@ -41,32 +42,45 @@ To install `PDFview.nvim` using **LazyVim**, add the following configuration to ```lua { - "basola21/PDFview", -- (or use this fork: "MadKuntilanak/PDFview") + "basola21/PDFview", event = "VeryLazy", dependencies = { "nvim-telescope/telescope.nvim" }, opts = { - path = "/path/to/pdf_folder", + 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", - }, + keys = { + { + "fp", + function() + require("pdfview").select_file_pdf() + end, + desc = "Select pdf file", + }, + { + "fP", + function() + require("pdfview").menu() + end, + desc = "Open menu", + }, }, }, ``` @@ -77,6 +91,9 @@ The `next_page` and `prev_page` keymaps are already set up for you in the `opts. 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" }) @@ -92,6 +109,8 @@ map("n", "kk", ":lua require('pdfview.renderer').previous_page() (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 + +---@param state PDFviewStateRender +local function setup_pdfview_ft_mappings(ctx, state) + local bufnr = ctx.buf + + vim.keymap.set("n", Config.defaults.keymaps.go_to_page, function() + require("pdfview").go_to(nil, state) + end, { + buf = bufnr, + desc = "go to page", + }) + + vim.keymap.set("n", Config.defaults.keymaps.show_page_in_zathura, function() + require("pdfview").open_in_zathura(nil, state) + end, { + buf = bufnr, + desc = "show page in zathura", + }) + + vim.keymap.set("n", Config.defaults.keymaps.next_page, renderer.next_page, { + buf = bufnr, + desc = "next page", + }) + + vim.keymap.set("n", Config.defaults.keymaps.prev_page, renderer.previous_page, { + buf = bufnr, + desc = "previous page", + }) + + vim.keymap.set("n", Config.defaults.keymaps.bookmark, function() + require("pdfview").bookmark() + end, { + buf = bufnr, + desc = "select bookmarks", + }) + + vim.keymap.set("n", Config.defaults.keymaps.save, function() + save(state) + end, { + buf = bufnr, + desc = "bookmark save", + }) + + vim.keymap.set("n", Config.defaults.keymaps.menu, function() + require("pdfview").menu() + end, { + buf = bufnr, + desc = "open menu", + }) +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 {key: string|string[],fun: string|function, desc: string, buf?: integer }[] +function M.append_to(tbl_keys) + for _, k in pairs(tbl_keys) do + if type(k.key) == "table" then + for _, __k in pairs(k.key) do + if type(k.fun) == "function" then + vim.keymap.set("n", __k, k.fun, { desc = k.desc, buf = k.buf }) + end + end + else + if type(k.fun) == "function" then + vim.keymap.set("n", k.key, k.fun, { desc = k.desc, buf = 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/pickers/default.lua b/lua/pdfview/pickers/default.lua index 841f026..e11970c 100644 --- a/lua/pdfview/pickers/default.lua +++ b/lua/pdfview/pickers/default.lua @@ -1,8 +1,9 @@ +local Util = require "pdfview.utils" local UtilPicker = require "pdfview.pickers.utils" local M = {} -local function get_files(path) +local function list_files(path) local cmd = UtilPicker.find_command(path) local files = vim.fn.systemlist(cmd) return files @@ -10,9 +11,9 @@ end ---@param path string ---@param cb function -function M.select_file_pdf(path, cb) - local files = get_files(path) - vim.ui.select(files, { prompt = "PDFview" }, function(file) +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 @@ -22,4 +23,69 @@ function M.select_file_pdf(path, cb) 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 + pdf_bookmarks[i] = nil + + Util.save_table_to_file(pdf_bookmarks, file_saved) + Util.info(_pdf.text_path .. " removed.") + return + end + end + end) +end + return M diff --git a/lua/pdfview/pickers/fzf-lua.lua b/lua/pdfview/pickers/fzf-lua.lua index 4b71bcf..47b1fe0 100644 --- a/lua/pdfview/pickers/fzf-lua.lua +++ b/lua/pdfview/pickers/fzf-lua.lua @@ -1,4 +1,5 @@ local Util = require "pdfview.utils" +local UtilPicker = require "pdfview.pickers.utils" local M = {} @@ -28,11 +29,6 @@ local function setup_fzflua() return FzfLua end ----@param title string -local function format_title(title) - return " " .. title .. " " -end - local Mapping = {} ---@param path string @@ -51,9 +47,61 @@ function Mapping.default(path, cb) end end +---@param pdf_bookmarks PDFviewBookmarkSaved[] +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 + pdf_bookmarks[i] = nil + + 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(_pdf.text_path .. " removed.") + + -- unplanned: should resume or exit? + -- FzfLua.actions.resume() + return + end + end + end +end + ---@param path string ---@param cb function -function M.select_file_pdf(path, cb) +function M.files(path, cb) setup_fzflua() if not loaded then @@ -63,13 +111,44 @@ function M.select_file_pdf(path, cb) FzfLua.files { cwd = path, no_header = true, - no_header_i = true, -- hide interactive header? + no_header_i = true, fzf_opts = { ["--header"] = [[^x:delete ^r:rename]] }, - winopts = { title = format_title "PDFview", preview = { hidden = true } }, + winopts = { title = Util.format_title "pdf files", preview = { hidden = true } }, 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"] = [[^x: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 + return M diff --git a/lua/pdfview/pickers/init.lua b/lua/pdfview/pickers/init.lua index 079d12e..8df06f7 100644 --- a/lua/pdfview/pickers/init.lua +++ b/lua/pdfview/pickers/init.lua @@ -39,11 +39,11 @@ end ---@param picker_name string ---@param path string ---@param cb function -function M.select_file(picker_name, path, cb) +function M.select(picker_name, method, path, cb) local p = get_picker(picker_name) - if p then - p.select_file_pdf(path, cb) + if p and p[method] then + p[method](path, cb) end end diff --git a/lua/pdfview/pickers/telescope.lua b/lua/pdfview/pickers/telescope.lua index 18a76f6..f06b25f 100644 --- a/lua/pdfview/pickers/telescope.lua +++ b/lua/pdfview/pickers/telescope.lua @@ -2,7 +2,9 @@ local telescope = require "telescope.builtin" local previewers = require "telescope.previewers" local actions = require "telescope.actions" local action_state = require "telescope.actions.state" -local util = require "pdfview.pickers.utils" + +local Util = require "pdfview.utils" +local UtilPicker = require "pdfview.pickers.utils" local M = {} @@ -18,10 +20,10 @@ local pdf_previewer = previewers.new_buffer_previewer { -- Telescope function with preview and open functionality ---@param path string ---@param cb function -function M.telescope_open(path, cb) +function M.files(path, cb) telescope.find_files { - prompt_title = "PDFview", - find_command = util.find_command(path), + 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) @@ -36,4 +38,10 @@ function M.telescope_open(path, cb) } end +---@param path string +---@param cb function +function M.bookmark(path, cb) + Util.not_implemented_yet() +end + return M diff --git a/lua/pdfview/pickers/utils.lua b/lua/pdfview/pickers/utils.lua index 934280d..a1d6ac2 100644 --- a/lua/pdfview/pickers/utils.lua +++ b/lua/pdfview/pickers/utils.lua @@ -1,9 +1,42 @@ +local Util = require "pdfview.utils" + local M = {} +---@param pdf_bookmarks PDFviewBookmarkSaved[] +local padding = function(pdf_bookmarks) + local _pad = 0 + for _, x in pairs(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 + return M diff --git a/lua/pdfview/renderer.lua b/lua/pdfview/renderer.lua index a743fb4..70d4d3c 100644 --- a/lua/pdfview/renderer.lua +++ b/lua/pdfview/renderer.lua @@ -4,91 +4,24 @@ 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, pages = {}, } ----@param page_num number|nil -local function __go_to(page_num) - if page_num then - state.current_page = page_num - M.display_current_page() - 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 - return - end - - state.current_page = num - - Util.info(string.format("Go to page: %d", state.current_page)) - M.display_current_page() - end) -end - ----@param page_num number|nil -local function __open_in_zathura(page_num) - 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_cmd(zathura_cmd) -end - -local function setup_pdfview_ft_mappings(ctx) - local bufnr = ctx.buf - - vim.keymap.set("n", Config.defaults.keymaps.go_to_page, __go_to, { - buf = bufnr, - desc = "Go to page", - }) - - vim.keymap.set("n", Config.defaults.keymaps.show_page_in_zathura, __open_in_zathura, { - buf = bufnr, - desc = "Show page in zathura", - }) - - vim.keymap.set("n", Config.defaults.keymaps.next_page, M.next_page, { - buf = bufnr, - desc = "next page", - }) - - vim.keymap.set("n", Config.defaults.keymaps.prev_page, M.previous_page, { - buf = bufnr, - desc = "previous page", - }) -end - -function M.setup_filetype_mappings(group) - vim.api.nvim_create_autocmd("FileType", { - group = group, - pattern = state.filetype, - desc = "CodeCompanion filetype mappings", - callback = setup_pdfview_ft_mappings, - }) -end - -- 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 }) @@ -115,6 +48,10 @@ function M.update_page_info() end local buf = state.buf + 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) @@ -146,13 +83,19 @@ function M.display_text(text, start_page, pdf_path) state.pages = M.paginate_text(text) state.total_pages = #state.pages - state.current_page = 1 + + 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 vim.api.nvim_set_option_value("buftype", "nofile", { buf = buf }) @@ -164,7 +107,10 @@ function M.display_text(text, start_page, pdf_path) state.ns_id = vim.api.nvim_create_namespace "PDFview" M.display_current_page() - 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 diff --git a/lua/pdfview/types.lua b/lua/pdfview/types.lua index 80c564e..f5aa45b 100644 --- a/lua/pdfview/types.lua +++ b/lua/pdfview/types.lua @@ -4,10 +4,38 @@ ---@field show_page_in_zathura string ---@field next_page string ---@field prev_page string +---@field bookmark string +---@field menu string +---@field save string + +---@class PDFviewPopup +---@field winhighlight 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 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 page_offset integer diff --git a/lua/pdfview/ui.lua b/lua/pdfview/ui.lua new file mode 100644 index 0000000..f0461f3 --- /dev/null +++ b/lua/pdfview/ui.lua @@ -0,0 +1,268 @@ +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" + + 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[] +local function apply_higlights(buf, lines) + local ns = vim.api.nvim_create_namespace(_ui.ns) + vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) + + 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 = 20 + 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 editor_size = get_editor_size() + local height = math.floor(editor_size.height * 20 / 100) + local width = math.floor(editor_size.width * 20 / 100) + + ---@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) + + local lines = { + "Select file pdf", + "Bookmark", + -- "History", + } + + if vim.bo.filetype ~= "pdfview" then + lines[#lines + 1] = "Last bookmark" + end + + if vim.bo.filetype == "pdfview" then + lines[#lines + 1] = "Open in zathura" + lines[#lines + 1] = "Go to" + + if cfg.picker and cfg.picker == "default" then + lines[#lines + 1] = "Delete item bookmark" + end + end + + ---@type table + local hval = {} + local display_lines = {} + + for i, item in ipairs(lines) do + local shortcut = item:sub(1, 1) + shortcut = shortcut:lower() + table.insert(display_lines, string.format(" %s %-20s %s", "●", item, shortcut)) + hval[i] = { + idx = i, + item = item, + shortcut = shortcut, + method = item:gsub(" ", "_"):lower(), + } + end + + height = math.min(#display_lines + 1, height) + + ---@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" } + ) + + apply_higlights(main_buf, display_lines) + + 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 89ef553..e442ca1 100644 --- a/lua/pdfview/utils.lua +++ b/lua/pdfview/utils.lua @@ -106,7 +106,7 @@ function M.create_dir(path) end end -function M.system_cmd(cmds) +function M.system_command(cmds) vim.system(cmds, { detach = true }, function(res) if res.code ~= 0 then vim.schedule(function() @@ -130,4 +130,108 @@ function M.clear_autocmd_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) + if type(opts.win) == "table" then + for _, w in pairs(opts.win) do + if w and vim.api.nvim_win_is_valid(w) then + vim.api.nvim_win_close(w, true) + end + end + return + end + + if type(opts.win) == "number" then + if opts.win and vim.api.nvim_win_is_valid(opts.win) then + vim.api.nvim_win_close(opts.win, true) + end + end + + if type(opts.buf) == "table" then + for _, b in pairs(opts.buf) do + if b and vim.api.nvim_buf_is_valid(b) then + delete_bufnr(b) + end + 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 "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 + +---@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 + +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 + return M From 66da9d4297f57d586ee7d9c5ddebd01bfb7d8eba Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Fri, 10 Jul 2026 03:28:45 +0700 Subject: [PATCH 06/19] feat: add automatic picker detection and fallback --- README.md | 2 +- lua/pdfview/init.lua | 4 ++-- lua/pdfview/pickers/fzf-lua.lua | 7 +++++- lua/pdfview/pickers/init.lua | 37 +++++++++++++++++++++++-------- lua/pdfview/pickers/telescope.lua | 7 +++++- 5 files changed, 43 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 861bf72..8a29806 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ## Features -- **Open PDF Files**: Quickly search and open PDF files using your preferred picker; `telescope.nvim`, `fzf-lua`, or the built-in default (`vim.ui.select`). +- **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. diff --git a/lua/pdfview/init.lua b/lua/pdfview/init.lua index 50e94b5..6b37793 100644 --- a/lua/pdfview/init.lua +++ b/lua/pdfview/init.lua @@ -109,7 +109,7 @@ function M.go_to(page_num, state) end vim.ui.input({ - prompt = "Go to page", + prompt = "Go to page: ", }, function(input) if not input then return @@ -160,7 +160,7 @@ function M.open(pdf_path, opts) opts.last_page = 1 end - if last_open_pdf and last_open_pdf == opts.pdf_path then + 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 diff --git a/lua/pdfview/pickers/fzf-lua.lua b/lua/pdfview/pickers/fzf-lua.lua index 47b1fe0..16a1dbd 100644 --- a/lua/pdfview/pickers/fzf-lua.lua +++ b/lua/pdfview/pickers/fzf-lua.lua @@ -99,6 +99,11 @@ function Mapping.delete_bookmark(pdf_bookmarks) 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) @@ -113,7 +118,7 @@ function M.files(path, cb) no_header = true, no_header_i = true, fzf_opts = { ["--header"] = [[^x:delete ^r:rename]] }, - winopts = { title = Util.format_title "pdf files", preview = { hidden = true } }, + winopts = { title = Util.format_title "pdf files", preview = { hidden = false } }, actions = { ["default"] = Mapping.default(path, cb), }, diff --git a/lua/pdfview/pickers/init.lua b/lua/pdfview/pickers/init.lua index 8df06f7..68ea741 100644 --- a/lua/pdfview/pickers/init.lua +++ b/lua/pdfview/pickers/init.lua @@ -2,17 +2,34 @@ 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", "telescope" } + +local silent_warn_notify = false + +---@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 + + if not silent_warn_notify then + Util.warn "The picker is falling back to the default `vim.ui.select`." + silent_warn_notify = true + end + return "default" end -local silent_warn_notify = false - ---@param picker_name string? local function get_picker(picker_name) picker_name = picker_name or "" - if Util.is_blank(picker_name) or silent_warn_notify then + if Util.is_blank(picker_name) then picker_name = default_picker() end @@ -21,18 +38,18 @@ local function get_picker(picker_name) if not ok then if not silent_warn_notify then Util.warn( - string.format( - "The picker `%s` has not been implemented yet.\nFalling back to the default `vim.ui.select`.", - picker_name - ) + string.format("The picker `%s` is not installed.\nFalling back to the default `vim.ui.select`.", picker_name) ) + silent_warn_notify = true end - silent_warn_notify = true - return get_picker "default" end + if picker.is_available and not picker.is_available() then + return get_picker() + end + return picker end @@ -44,6 +61,8 @@ function M.select(picker_name, method, path, cb) if p and p[method] then p[method](path, cb) + else + Util.warn("The picker '" .. picker_name .. "' does not implement the '" .. method .. "' method.") end end diff --git a/lua/pdfview/pickers/telescope.lua b/lua/pdfview/pickers/telescope.lua index f06b25f..abed5fe 100644 --- a/lua/pdfview/pickers/telescope.lua +++ b/lua/pdfview/pickers/telescope.lua @@ -12,11 +12,16 @@ local M = {} 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) + 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, } +---@return boolean +function M.is_available() + return (pcall(require, "telescope")) +end + -- Telescope function with preview and open functionality ---@param path string ---@param cb function From 050ef4f2333dc159708539bf11f485c6951877b6 Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Tue, 14 Jul 2026 05:22:51 +0700 Subject: [PATCH 07/19] feat: implement search functionality --- TODO.org | 6 ++ lua/pdfview/config.lua | 8 +- lua/pdfview/init.lua | 106 ++++++++++++++++++++++----- lua/pdfview/keymaps.lua | 118 ++++++++++++++++++------------ lua/pdfview/parser.lua | 4 +- lua/pdfview/pickers/default.lua | 32 ++++++++ lua/pdfview/pickers/fzf-lua.lua | 48 ++++++++++++ lua/pdfview/pickers/init.lua | 39 +++++++--- lua/pdfview/pickers/telescope.lua | 4 + lua/pdfview/renderer.lua | 5 +- lua/pdfview/search.lua | 51 +++++++++++++ lua/pdfview/types.lua | 28 ++++++- lua/pdfview/ui.lua | 21 +++++- lua/pdfview/utils.lua | 87 +++++++++++++++++----- 14 files changed, 450 insertions(+), 107 deletions(-) create mode 100644 lua/pdfview/search.lua diff --git a/TODO.org b/TODO.org index 7b582e1..0872061 100644 --- a/TODO.org +++ b/TODO.org @@ -3,3 +3,9 @@ - [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 index 6d44d73..a6912c8 100644 --- a/lua/pdfview/config.lua +++ b/lua/pdfview/config.lua @@ -17,8 +17,12 @@ M.defaults = { show_page_in_zathura = "x", next_page = "", prev_page = "", - bookmark = "b", - save = "s", + open_bookmark = "b", + save_bookmark = "s", + search = "", + pick_search = "s", + next_search_text = "", + prev_search_text = "", }, } diff --git a/lua/pdfview/init.lua b/lua/pdfview/init.lua index 6b37793..086d1e3 100644 --- a/lua/pdfview/init.lua +++ b/lua/pdfview/init.lua @@ -3,6 +3,8 @@ 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 = {} @@ -20,17 +22,6 @@ function M.setup(opts) keymaps.setup_filetype_mappings(group, renderer.get()) end -local p - -local function load_picker() - if not p then - p = require "pdfview.pickers" - return p - end - - return p -end - ---@param pdf_path string local function ensure_callback(pdf_path) if Config.defaults.open and Config.defaults.open.cb then @@ -47,8 +38,7 @@ function M.select_file_pdf() return end - p = load_picker() - p.select(Config.defaults.picker or "default", "files", path, function(pdf_path) + 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 @@ -60,21 +50,29 @@ function M.select_file_pdf() end) end -function M.bookmark() +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 - p = load_picker() - p.select(Config.defaults.picker or "default", "bookmark", file_saved, function(opts) + 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_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.last_bookmark() local pdf_bookmarks = Util.get_pdf_bookmarks() if not pdf_bookmarks then @@ -98,13 +96,17 @@ end ---@param page_num number|nil ---@param state? PDFviewStateRender -function M.go_to(page_num, state) +---@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() - Util.info(string.format("Go to page: %d", state.current_page)) + if notify then + Util.info(string.format("Go to page: %d", state.current_page)) + end return end @@ -117,12 +119,15 @@ function M.go_to(page_num, state) local num = tonumber(input) if not num then + Util.warn("go_to", "not a number `" .. input .. "`") return end state.current_page = num - Util.info(string.format("Go to page: %d", state.current_page)) + if notify then + Util.info(string.format("Go to page: %d", state.current_page)) + end renderer.display_current_page() end) end @@ -173,6 +178,69 @@ function M.open(pdf_path, opts) 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) + 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" + + -- 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.search_text(pdf_path, query) + if pdf_path and query then + search_to(pdf_path, query) + end + + vim.ui.input({ + prompt = "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 diff --git a/lua/pdfview/keymaps.lua b/lua/pdfview/keymaps.lua index 79b4f51..d2e4b7f 100644 --- a/lua/pdfview/keymaps.lua +++ b/lua/pdfview/keymaps.lua @@ -39,53 +39,77 @@ local function save(state) Util.info("bookmark", string.format("Saved: %s · %s", _data.text_page, _data.text_path)) end +local idx = 1 + ---@param state PDFviewStateRender -local function setup_pdfview_ft_mappings(ctx, state) - local bufnr = ctx.buf +---@param step integer +local function search(state, step) + if not state.search or not state.search.cache or not state.search.current_query then + return + end - vim.keymap.set("n", Config.defaults.keymaps.go_to_page, function() - require("pdfview").go_to(nil, state) - end, { - buf = bufnr, - desc = "go to page", - }) + local items = state.search.cache[state.search.current_query] - vim.keymap.set("n", Config.defaults.keymaps.show_page_in_zathura, function() - require("pdfview").open_in_zathura(nil, state) - end, { - buf = bufnr, - desc = "show page in zathura", - }) + local total_items = #items + idx = ((idx - 1 + step) % total_items) + 1 + local item = items[idx] - vim.keymap.set("n", Config.defaults.keymaps.next_page, renderer.next_page, { - buf = bufnr, - desc = "next page", - }) + require("pdfview").go_to(item.page, state) + Util.__add_buf_highlight(item, state) +end - vim.keymap.set("n", Config.defaults.keymaps.prev_page, renderer.previous_page, { - buf = bufnr, - desc = "previous page", - }) +---@param state PDFviewStateRender +local function next_search_text(state) + search(state, 1) +end - vim.keymap.set("n", Config.defaults.keymaps.bookmark, function() - require("pdfview").bookmark() - end, { - buf = bufnr, - desc = "select bookmarks", - }) +---@param state PDFviewStateRender +local function prev_search_text(state) + search(state, -1) +end - vim.keymap.set("n", Config.defaults.keymaps.save, function() - save(state) - end, { - buf = bufnr, - desc = "bookmark save", - }) +---@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) end, desc = "next search result", buf = bufnr }, + { key = keymaps.prev_search_text, fun = function() prev_search_text(state) end, desc = "previous search result", buf = bufnr }, + } - vim.keymap.set("n", Config.defaults.keymaps.menu, function() - require("pdfview").menu() - end, { - buf = bufnr, - desc = "open menu", + 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 + pcall(vim.keymap.del, "n", "", { buffer = bufnr }) + if state.search then + state.search = nil + end + if state.pages then + state.pages = nil + end + end + end, }) end @@ -102,18 +126,16 @@ local function augroup(group, state, cb) }) end ----@param tbl_keys {key: string|string[],fun: string|function, desc: string, buf?: integer }[] +---@param tbl_keys PDFviewKeySpec function M.append_to(tbl_keys) for _, k in pairs(tbl_keys) do - if type(k.key) == "table" then - for _, __k in pairs(k.key) do - if type(k.fun) == "function" then - vim.keymap.set("n", __k, k.fun, { desc = k.desc, buf = k.buf }) - end - end - else + 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", k.key, k.fun, { desc = k.desc, buf = k.buf }) + vim.keymap.set("n", key, k.fun, { + desc = k.desc, + buffer = k.buf, + }) end end end diff --git a/lua/pdfview/parser.lua b/lua/pdfview/parser.lua index f4c3587..2d3f0fc 100644 --- a/lua/pdfview/parser.lua +++ b/lua/pdfview/parser.lua @@ -22,7 +22,7 @@ local function run_command(cmd) if result and #result > 0 then return result else - Util.error "PDFview: Failed to extract text from PDF." + Util.error("parser", "Failed to extract text from PDF.") return nil end end @@ -30,7 +30,7 @@ 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 Util.is_file(pdf_path) then - Util.error("PDFview: File does not exist: " .. pdf_path) + Util.error("parser", "File does not exist: " .. pdf_path) return nil end diff --git a/lua/pdfview/pickers/default.lua b/lua/pdfview/pickers/default.lua index e11970c..9274883 100644 --- a/lua/pdfview/pickers/default.lua +++ b/lua/pdfview/pickers/default.lua @@ -88,4 +88,36 @@ function M.delete_item_bookmark() end) end +function M.search() + local renderer = require "pdfview.renderer" + local state = renderer.get() + + 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 contents = {} + local seen = {} + for _, x in pairs(items) do + contents[#contents + 1] = string.format(x.text_line) + seen[string.format(x.text_line)] = x + end + + vim.ui.select(contents, { prompt = Util.format_title "query:" .. state.search.current_query }, function(selection) + if not selection then + return + end + + local sel = selection + local item = seen[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 index 16a1dbd..38e1587 100644 --- a/lua/pdfview/pickers/fzf-lua.lua +++ b/lua/pdfview/pickers/fzf-lua.lua @@ -99,6 +99,25 @@ function Mapping.delete_bookmark(pdf_bookmarks) end end +---@param state PDFviewStateRender +---@param seen table +function Mapping.search(seen, state) + return function(selection) + if not selection then + return + end + + local sel = selection[1] + local item = seen[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")) @@ -156,4 +175,33 @@ function M.bookmark(path, cb) }) end +function M.search() + setup_fzflua() + + local renderer = require "pdfview.renderer" + local state = renderer.get() + + 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 contents = {} + local seen = {} + for _, x in pairs(items) do + contents[#contents + 1] = string.format(x.text_line) + seen[string.format(x.text_line)] = x + end + + FzfLua.fzf_exec(contents, { + no_header = true, + no_header_i = true, + fzf_opts = { ["--header"] = [[^x:delete]] }, + winopts = { title = Util.format_title "query:" .. state.search.current_query, 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 index 68ea741..2834610 100644 --- a/lua/pdfview/pickers/init.lua +++ b/lua/pdfview/pickers/init.lua @@ -4,9 +4,11 @@ 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", "telescope" } +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() @@ -26,7 +28,7 @@ local function default_picker() end ---@param picker_name string? -local function get_picker(picker_name) +local function resolve_picker(picker_name) picker_name = picker_name or "" if Util.is_blank(picker_name) then @@ -37,32 +39,45 @@ local function get_picker(picker_name) if not ok then if not silent_warn_notify then - Util.warn( - string.format("The picker `%s` is not installed.\nFalling back to the default `vim.ui.select`.", picker_name) - ) - silent_warn_notify = true + warn_msg_picker_not_installed = string.format("The picker `%s` is not installed", picker_name) end - return get_picker "default" + set_picker = default_picker() + return resolve_picker(set_picker) end if picker.is_available and not picker.is_available() then - return get_picker() + return resolve_picker() end + if ok and not silent_warn_notify and warn_msg_picker_not_installed then + Util.warn(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 p + +local function setup_picker(picker_name) + if p then + return p + end + p = resolve_picker(picker_name) + return p +end + ---@param picker_name string ---@param path string ----@param cb function +---@param cb function|nil function M.select(picker_name, method, path, cb) - local p = get_picker(picker_name) - + p = setup_picker(picker_name) if p and p[method] then p[method](path, cb) else - Util.warn("The picker '" .. picker_name .. "' does not implement the '" .. method .. "' method.") + Util.warn("picker", "The picker '" .. set_picker .. "' does not implement the '" .. method .. "' method.") end end diff --git a/lua/pdfview/pickers/telescope.lua b/lua/pdfview/pickers/telescope.lua index abed5fe..1e1d670 100644 --- a/lua/pdfview/pickers/telescope.lua +++ b/lua/pdfview/pickers/telescope.lua @@ -49,4 +49,8 @@ function M.bookmark(path, cb) Util.not_implemented_yet() end +function M.search() + Util.not_implemented_yet() +end + return M diff --git a/lua/pdfview/renderer.lua b/lua/pdfview/renderer.lua index 70d4d3c..6d1a745 100644 --- a/lua/pdfview/renderer.lua +++ b/lua/pdfview/renderer.lua @@ -13,6 +13,7 @@ local state = { page_offset = 0, buf = nil, ns_id = nil, + ns_search_id = nil, pages = {}, } @@ -119,7 +120,7 @@ function M.next_page() state.current_page = state.current_page + 1 M.display_current_page() else - Util.warn "PDFview: Already at the last page." + Util.warn("renderer", "Already at the last page.") end end @@ -129,7 +130,7 @@ function M.previous_page() state.current_page = state.current_page - 1 M.display_current_page() else - Util.warn "PDFview: Already at the first page." + Util.warn("renderer", "Already at the firts page.") end end diff --git a/lua/pdfview/search.lua b/lua/pdfview/search.lua new file mode 100644 index 0000000..c97773d --- /dev/null +++ b/lua/pdfview/search.lua @@ -0,0 +1,51 @@ +local Parser = require "pdfview.parser" +local M = {} + +-- Split full pdftotext output into pages (pdftotext inserts \f between pages) +---@param full_text string +local function split_pages(full_text) + local pages = {} + for page in (full_text .. "\f"):gmatch "(.-)\f" do + table.insert(pages, page) + end + return pages +end + +---@param pdf_path string +---@param query string +---@param opts? {case_sensitive: boolean, pages:table} +---@return PDFviewMatch[] +function M.find_matches(pdf_path, query, opts) + opts = opts or {} + local full_text = Parser.extract_text(pdf_path) -- no start/end = whole doc + if not full_text then + return {} + end + + local pages = split_pages(full_text) + local matches = {} + local pattern = opts.case_sensitive and query or query:lower() + + for page_num, page_text in ipairs(pages) do + local line_num = 0 + for line in (page_text .. "\n"):gmatch "(.-)\n" do + line_num = line_num + 1 + local haystack = opts.case_sensitive and line or line:lower() + local col = haystack:find(pattern, 1, true) -- plain-text find + if col then + local txt_trim = vim.trim(line) + table.insert(matches, { + filename = pdf_path, + page = page_num, + line = line_num, + col = col, + text = txt_trim, + text_line = string.format("[p.%d] %s", page_num, txt_trim), + }) + end + end + end + return matches +end + +return M diff --git a/lua/pdfview/types.lua b/lua/pdfview/types.lua index f5aa45b..b7da337 100644 --- a/lua/pdfview/types.lua +++ b/lua/pdfview/types.lua @@ -1,12 +1,21 @@ +---@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 bookmark string +---@field open_bookmark string ---@field menu string ----@field save 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 @@ -30,6 +39,18 @@ ---@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 @@ -38,4 +59,7 @@ ---@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 search? PDFviewMatchQuery diff --git a/lua/pdfview/ui.lua b/lua/pdfview/ui.lua index f0461f3..7447258 100644 --- a/lua/pdfview/ui.lua +++ b/lua/pdfview/ui.lua @@ -44,7 +44,9 @@ function view.setup_ui_mappings(opts) local keymaps = require "pdfview.keymaps" local pdfview = require "pdfview" + ---@type PDFviewKeySpec[] local keys = {} + keys = { -- Enter { @@ -180,7 +182,7 @@ function view.menu(cfg) local lines = { "Select file pdf", - "Bookmark", + "Select bookmark", -- "History", } @@ -191,6 +193,8 @@ function view.menu(cfg) if vim.bo.filetype == "pdfview" then lines[#lines + 1] = "Open in zathura" lines[#lines + 1] = "Go to" + lines[#lines + 1] = "Search text" + lines[#lines + 1] = "Select search" if cfg.picker and cfg.picker == "default" then lines[#lines + 1] = "Delete item bookmark" @@ -201,9 +205,20 @@ function view.menu(cfg) 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 + for i, item in ipairs(lines) do - local shortcut = item:sub(1, 1) - shortcut = shortcut:lower() + local shortcut = resolve_shortcut(item) table.insert(display_lines, string.format(" %s %-20s %s", "●", item, shortcut)) hval[i] = { idx = i, diff --git a/lua/pdfview/utils.lua b/lua/pdfview/utils.lua index e442ca1..5308213 100644 --- a/lua/pdfview/utils.lua +++ b/lua/pdfview/utils.lua @@ -111,7 +111,7 @@ function M.system_command(cmds) if res.code ~= 0 then vim.schedule(function() ---@diagnostic disable-next-line: undefined-field - M.error("failed to open Zathura:" .. (res.stderr or "")) + M.error("utils", "failed to open Zathura:" .. (res.stderr or "")) end) end end) @@ -140,28 +140,31 @@ local function delete_bufnr(buf) vim.api.nvim_buf_delete(buf, { force = true }) end ----@param opts {buf: integer|integer[], win:integer|integer[]} +---@param opts {buf?: integer|integer[], win?: integer|integer[]} function M.close_win(opts) - if type(opts.win) == "table" then - for _, w in pairs(opts.win) do - if w and vim.api.nvim_win_is_valid(w) then - vim.api.nvim_win_close(w, true) - end + 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 - return end - if type(opts.win) == "number" then - if opts.win and vim.api.nvim_win_is_valid(opts.win) then - vim.api.nvim_win_close(opts.win, true) + 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 - if type(opts.buf) == "table" then - for _, b in pairs(opts.buf) do - if b and vim.api.nvim_buf_is_valid(b) then - delete_bufnr(b) - 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 @@ -175,7 +178,7 @@ function M.save_table_to_file(contents, filename) file:write(tostring(vim.inspect(contents))) file:close() else - M.warn "Failed to save data table to file" + M.warn("utils", "Failed to save data table to file") end end @@ -221,6 +224,39 @@ function M.get_pdf_bookmarks() return dofile(file_saved) or {} end +---@param item PDFviewMatch +---@param state PDFviewStateRender +function M.__add_buf_highlight(item, state) + 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 }) + + M.del_namespace(state.buf, state.ns_search_id) + local line_text = vim.api.nvim_buf_get_lines(state.buf, target_line - 1, target_line, false)[1] or "" + local end_col = #line_text + + -- opsional: add highlight? + 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) @@ -234,4 +270,21 @@ function M.set_extmark(bufnr, namespace_name, line, col, opts) 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 From 1406113df7facb7b72c95bc4f5d0a7baf57a3620 Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Tue, 14 Jul 2026 13:20:21 +0700 Subject: [PATCH 08/19] fix(pickers): trim selected entries before lookup --- lua/pdfview/pickers/default.lua | 6 +++--- lua/pdfview/pickers/fzf-lua.lua | 12 ++++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lua/pdfview/pickers/default.lua b/lua/pdfview/pickers/default.lua index 9274883..38769ec 100644 --- a/lua/pdfview/pickers/default.lua +++ b/lua/pdfview/pickers/default.lua @@ -100,8 +100,8 @@ function M.search() local contents = {} local seen = {} for _, x in pairs(items) do - contents[#contents + 1] = string.format(x.text_line) - seen[string.format(x.text_line)] = x + contents[#contents + 1] = x.text_line + seen[x.text_line] = x end vim.ui.select(contents, { prompt = Util.format_title "query:" .. state.search.current_query }, function(selection) @@ -110,7 +110,7 @@ function M.search() end local sel = selection - local item = seen[sel] + local item = seen[vim.trim(sel)] if not item then return end diff --git a/lua/pdfview/pickers/fzf-lua.lua b/lua/pdfview/pickers/fzf-lua.lua index 38e1587..09a5dc1 100644 --- a/lua/pdfview/pickers/fzf-lua.lua +++ b/lua/pdfview/pickers/fzf-lua.lua @@ -101,14 +101,18 @@ end ---@param state PDFviewStateRender ---@param seen table -function Mapping.search(seen, state) +function Mapping.search(state, seen) return function(selection) if not selection then return end local sel = selection[1] - local item = seen[sel] + if not sel then + return + end + + local item = seen[vim.trim(sel)] if not item then return end @@ -189,8 +193,8 @@ function M.search() local contents = {} local seen = {} for _, x in pairs(items) do - contents[#contents + 1] = string.format(x.text_line) - seen[string.format(x.text_line)] = x + contents[#contents + 1] = x.text_line + seen[x.text_line] = x end FzfLua.fzf_exec(contents, { From 946a0502bb657e1a5e28770f07ba0aa2920e92fe Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Tue, 14 Jul 2026 14:55:48 +0700 Subject: [PATCH 09/19] ref(pickers): extract search cache helper --- lua/pdfview/pickers/default.lua | 47 ++++++++++++++++++--------------- lua/pdfview/pickers/fzf-lua.lua | 26 +++++++++--------- lua/pdfview/pickers/utils.lua | 21 +++++++++++++++ 3 files changed, 59 insertions(+), 35 deletions(-) diff --git a/lua/pdfview/pickers/default.lua b/lua/pdfview/pickers/default.lua index 38769ec..e7c2172 100644 --- a/lua/pdfview/pickers/default.lua +++ b/lua/pdfview/pickers/default.lua @@ -78,7 +78,11 @@ function M.delete_item_bookmark() for i, _pdf in pairs(pdf_bookmarks) do if _pdf.text_page == sel_page_num and _pdf.text_path == sel_pdf_path then - pdf_bookmarks[i] = nil + 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.") @@ -92,32 +96,33 @@ function M.search() local renderer = require "pdfview.renderer" local state = renderer.get() - if not state.search or not state.search.cache or not state.search.current_query then + local data = UtilPicker.search_cache(state) + if not data then + Util.warn("picker", "No active search") return 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 + local contents = data.contents + local seen = data.seen - vim.ui.select(contents, { prompt = Util.format_title "query:" .. state.search.current_query }, function(selection) - if not selection then - return - end + 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 + 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) + 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 index 09a5dc1..75c39bb 100644 --- a/lua/pdfview/pickers/fzf-lua.lua +++ b/lua/pdfview/pickers/fzf-lua.lua @@ -48,6 +48,7 @@ function Mapping.default(path, cb) end ---@param pdf_bookmarks PDFviewBookmarkSaved[] +---@param cb function function Mapping.default_bookmark(pdf_bookmarks, cb) return function(selection) if not selection then @@ -82,10 +83,10 @@ function Mapping.delete_bookmark(pdf_bookmarks) for i, _pdf in pairs(pdf_bookmarks) do if _pdf.text_page == sel_page_num and _pdf.text_path == sel_pdf_path then - pdf_bookmarks[i] = nil + table.remove(pdf_bookmarks, i) table.sort(pdf_bookmarks, function(a, b) - return (a.created_at or 0) > (b.created_at or 0) + 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) @@ -140,7 +141,7 @@ function M.files(path, cb) cwd = path, no_header = true, no_header_i = true, - fzf_opts = { ["--header"] = [[^x:delete ^r:rename]] }, + -- fzf_opts = { ["--header"] = [[^x:delete ^r:rename]] }, winopts = { title = Util.format_title "pdf files", preview = { hidden = false } }, actions = { ["default"] = Mapping.default(path, cb), @@ -170,7 +171,7 @@ function M.bookmark(path, cb) FzfLua.fzf_exec(contents, { no_header = true, no_header_i = true, - fzf_opts = { ["--header"] = [[^x:delete]] }, + fzf_opts = { ["--header"] = [[ delete]] }, winopts = { title = Util.format_title "bookmarks", preview = { hidden = true } }, actions = { ["default"] = Mapping.default_bookmark(pdf_bookmarks, cb), @@ -185,23 +186,20 @@ function M.search() local renderer = require "pdfview.renderer" local state = renderer.get() - if not state.search or not state.search.cache or not state.search.current_query then + local data = UtilPicker.search_cache(state) + if not data then + Util.warn("picker.fzf-lua", "No active search") return 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 + local contents = data.contents + local seen = data.seen FzfLua.fzf_exec(contents, { no_header = true, no_header_i = true, - fzf_opts = { ["--header"] = [[^x:delete]] }, - winopts = { title = Util.format_title "query:" .. state.search.current_query, preview = { hidden = true } }, + fzf_opts = { ["--header"] = [[:delete]] }, + winopts = { title = Util.format_title "", preview = { hidden = true } }, actions = { ["default"] = Mapping.search(state, seen), }, diff --git a/lua/pdfview/pickers/utils.lua b/lua/pdfview/pickers/utils.lua index a1d6ac2..5d08447 100644 --- a/lua/pdfview/pickers/utils.lua +++ b/lua/pdfview/pickers/utils.lua @@ -39,4 +39,25 @@ function M.bookmark_contents(pdf_bookmarks) 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 From 085e6cb5c742214149a06e841ccfafe5097abd46 Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Tue, 14 Jul 2026 14:56:29 +0700 Subject: [PATCH 10/19] feat(pickers): add `telescope` picker --- lua/pdfview/pickers/telescope.lua | 165 +++++++++++++++++++++++++++++- 1 file changed, 163 insertions(+), 2 deletions(-) diff --git a/lua/pdfview/pickers/telescope.lua b/lua/pdfview/pickers/telescope.lua index 1e1d670..bb4b9ec 100644 --- a/lua/pdfview/pickers/telescope.lua +++ b/lua/pdfview/pickers/telescope.lua @@ -1,4 +1,7 @@ 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" @@ -17,6 +20,68 @@ local pdf_previewer = previewers.new_buffer_previewer { 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")) @@ -46,11 +111,107 @@ end ---@param path string ---@param cb function function M.bookmark(path, cb) - Util.not_implemented_yet() + 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() - Util.not_implemented_yet() + 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", #data, #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 From c885ea0e5561a3eb249111ae30d74c6f50a1721d Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Tue, 14 Jul 2026 15:29:31 +0700 Subject: [PATCH 11/19] fix: guard clauses, buffer safety, and namespace cleanup --- lua/pdfview/keymaps.lua | 2 +- lua/pdfview/pickers/init.lua | 7 ++++--- lua/pdfview/ui.lua | 6 +++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lua/pdfview/keymaps.lua b/lua/pdfview/keymaps.lua index d2e4b7f..19ebf3e 100644 --- a/lua/pdfview/keymaps.lua +++ b/lua/pdfview/keymaps.lua @@ -101,7 +101,7 @@ local function setup_pdfview_ft_mappings(ctx, state) once = true, callback = function() if vim.api.nvim_buf_is_valid(bufnr) then - pcall(vim.keymap.del, "n", "", { buffer = bufnr }) + -- pcall(vim.keymap.del, "n", "", { buffer = bufnr }) if state.search then state.search = nil end diff --git a/lua/pdfview/pickers/init.lua b/lua/pdfview/pickers/init.lua index 2834610..5be584c 100644 --- a/lua/pdfview/pickers/init.lua +++ b/lua/pdfview/pickers/init.lua @@ -74,11 +74,12 @@ end ---@param cb function|nil function M.select(picker_name, method, path, cb) p = setup_picker(picker_name) - if p and p[method] then - p[method](path, cb) - else + if not p or not p[method] then Util.warn("picker", "The picker '" .. set_picker .. "' does not implement the '" .. method .. "' method.") + return end + + p[method](path, cb) end return M diff --git a/lua/pdfview/ui.lua b/lua/pdfview/ui.lua index 7447258..10f284d 100644 --- a/lua/pdfview/ui.lua +++ b/lua/pdfview/ui.lua @@ -125,7 +125,7 @@ end ---@param lines string[] local function apply_higlights(buf, lines) local ns = vim.api.nvim_create_namespace(_ui.ns) - vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) + Util.del_namespace(buf, ns) for lnum, line in ipairs(lines) do if not line or line == "" then @@ -265,6 +265,10 @@ function view.menu(cfg) { 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) local opts = { buf = main_buf, win = main_win, hval = hval } From 9a6da917b286241bbb9d77a25bc62ba85012a12b Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Tue, 14 Jul 2026 20:19:12 +0700 Subject: [PATCH 12/19] fix(telescope): correct result count display --- lua/pdfview/pickers/telescope.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/pdfview/pickers/telescope.lua b/lua/pdfview/pickers/telescope.lua index bb4b9ec..df3f8ac 100644 --- a/lua/pdfview/pickers/telescope.lua +++ b/lua/pdfview/pickers/telescope.lua @@ -187,7 +187,7 @@ function M.search() pickers .new({}, { prompt_title = Util.format_title "", - results_title = string.format("%d %s found", #data, #contents == 1 and "result" or "results"), + results_title = string.format("%d %s found", #contents, #contents == 1 and "result" or "results"), finder = finders.new_table { results = contents, entry_maker = function(entry) From 69cacf1457b304c40b918c7ce7045cef4f4dd34a Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Tue, 14 Jul 2026 22:14:02 +0700 Subject: [PATCH 13/19] feat(picker): improve caching mechanism, improve fallback handling --- lua/pdfview/pickers/init.lua | 38 +++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/lua/pdfview/pickers/init.lua b/lua/pdfview/pickers/init.lua index 5be584c..7b4746a 100644 --- a/lua/pdfview/pickers/init.lua +++ b/lua/pdfview/pickers/init.lua @@ -2,8 +2,7 @@ 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. +-- 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 @@ -18,12 +17,6 @@ local function default_picker() return name end end - - if not silent_warn_notify then - Util.warn "The picker is falling back to the default `vim.ui.select`." - silent_warn_notify = true - end - return "default" end @@ -51,7 +44,10 @@ local function resolve_picker(picker_name) end if ok and not silent_warn_notify and warn_msg_picker_not_installed then - Util.warn(string.format("%s.\nFalling back to the picker `%s`.", warn_msg_picker_not_installed, picker_name)) + Util.warn( + "picker", + string.format("%s.\nFalling back to the picker `%s`.", warn_msg_picker_not_installed, picker_name) + ) silent_warn_notify = true end @@ -59,27 +55,33 @@ local function resolve_picker(picker_name) return picker end -local p +local resolved_pickers = {} local function setup_picker(picker_name) - if p then - return p + local cache_key = picker_name or "__auto__" + if resolved_pickers[cache_key] then + return resolved_pickers[cache_key] end - p = resolve_picker(picker_name) - return p + 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) - p = setup_picker(picker_name) - if not p or not p[method] then - Util.warn("picker", "The picker '" .. set_picker .. "' does not implement the '" .. method .. "' method.") + 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 - p[method](path, cb) + picker[method](path, cb) end return M From 029746bbef1df849fa852680fba12dc66d765fde Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Wed, 15 Jul 2026 01:53:28 +0700 Subject: [PATCH 14/19] feat(pickers): add `snacks` picker --- lua/pdfview/pickers/snacks.lua | 198 +++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 lua/pdfview/pickers/snacks.lua 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 From b467a6e307632b8ece67738a496b598a6dcdaf77 Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Wed, 15 Jul 2026 01:53:49 +0700 Subject: [PATCH 15/19] feat: implement text search --- lua/pdfview/config.lua | 7 ++ lua/pdfview/init.lua | 2 +- lua/pdfview/keymaps.lua | 30 ++++++--- lua/pdfview/pickers/utils.lua | 2 +- lua/pdfview/types.lua | 2 + lua/pdfview/utils.lua | 120 +++++++++++++++++++++++++++++++++- 6 files changed, 149 insertions(+), 14 deletions(-) diff --git a/lua/pdfview/config.lua b/lua/pdfview/config.lua index a6912c8..4abd246 100644 --- a/lua/pdfview/config.lua +++ b/lua/pdfview/config.lua @@ -10,6 +10,13 @@ M.defaults = { }, window = { winhighlight = nil, + text_search_indicator = { + sep_front = "", + sep_back = "", + hl = "WarningMsg", + attr = "fg", + icon = " ", + }, }, keymaps = { menu = "", diff --git a/lua/pdfview/init.lua b/lua/pdfview/init.lua index 086d1e3..7e75ba2 100644 --- a/lua/pdfview/init.lua +++ b/lua/pdfview/init.lua @@ -228,7 +228,7 @@ function M.search_text(pdf_path, query) end vim.ui.input({ - prompt = "Search: ", + prompt = "Text Search: ", }, function(q) if not q then return diff --git a/lua/pdfview/keymaps.lua b/lua/pdfview/keymaps.lua index 19ebf3e..97960d3 100644 --- a/lua/pdfview/keymaps.lua +++ b/lua/pdfview/keymaps.lua @@ -43,7 +43,8 @@ local idx = 1 ---@param state PDFviewStateRender ---@param step integer -local function search(state, step) +---@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 @@ -55,17 +56,29 @@ local function search(state, step) local item = items[idx] require("pdfview").go_to(item.page, state) - Util.__add_buf_highlight(item, 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) + end end ---@param state PDFviewStateRender -local function next_search_text(state) - search(state, 1) +---@param bufnr integer +local function next_search_text(state, bufnr) + search(state, 1, bufnr) end ---@param state PDFviewStateRender -local function prev_search_text(state) - search(state, -1) +---@param bufnr integer +local function prev_search_text(state, bufnr) + search(state, -1, bufnr) end ---@param ctx vim.api.keyset.create_autocmd.callback_args @@ -88,8 +101,8 @@ local function setup_pdfview_ft_mappings(ctx, state) { 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) end, desc = "next search result", buf = bufnr }, - { key = keymaps.prev_search_text, fun = function() prev_search_text(state) end, desc = "previous 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) @@ -101,7 +114,6 @@ local function setup_pdfview_ft_mappings(ctx, state) once = true, callback = function() if vim.api.nvim_buf_is_valid(bufnr) then - -- pcall(vim.keymap.del, "n", "", { buffer = bufnr }) if state.search then state.search = nil end diff --git a/lua/pdfview/pickers/utils.lua b/lua/pdfview/pickers/utils.lua index 5d08447..95fd4ce 100644 --- a/lua/pdfview/pickers/utils.lua +++ b/lua/pdfview/pickers/utils.lua @@ -5,7 +5,7 @@ local M = {} ---@param pdf_bookmarks PDFviewBookmarkSaved[] local padding = function(pdf_bookmarks) local _pad = 0 - for _, x in pairs(pdf_bookmarks) do + for _, x in ipairs(pdf_bookmarks) do if _pad < #x.text_page then _pad = #x.text_page end diff --git a/lua/pdfview/types.lua b/lua/pdfview/types.lua index b7da337..4e6878b 100644 --- a/lua/pdfview/types.lua +++ b/lua/pdfview/types.lua @@ -19,6 +19,7 @@ ---@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 @@ -62,4 +63,5 @@ ---@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/utils.lua b/lua/pdfview/utils.lua index 5308213..2343ddb 100644 --- a/lua/pdfview/utils.lua +++ b/lua/pdfview/utils.lua @@ -214,6 +214,10 @@ function M.strip_whitespace(str) 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" @@ -224,9 +228,110 @@ function M.get_pdf_bookmarks() 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 -function M.__add_buf_highlight(item, state) +---@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) @@ -237,11 +342,20 @@ function M.__add_buf_highlight(item, state) vim.api.nvim_win_set_cursor(state.win, { target_line, target_col }) - M.del_namespace(state.buf, state.ns_search_id) local line_text = vim.api.nvim_buf_get_lines(state.buf, target_line - 1, target_line, false)[1] or "" local end_col = #line_text - -- opsional: add highlight? + 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, From 43f12684002f0833b442221e54394feb4085507f Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Wed, 15 Jul 2026 02:35:37 +0700 Subject: [PATCH 16/19] ref(search): reuse cached pages from render state, add notify --- lua/pdfview/init.lua | 4 +++- lua/pdfview/search.lua | 45 +++++++++++++++++++----------------------- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/lua/pdfview/init.lua b/lua/pdfview/init.lua index 7e75ba2..c0598d2 100644 --- a/lua/pdfview/init.lua +++ b/lua/pdfview/init.lua @@ -188,7 +188,7 @@ local function search_to(pdf_path, query, state) 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) + matches = search.find_matches(pdf_path, query, state) end if #matches == 0 then @@ -205,6 +205,8 @@ local function search_to(pdf_path, query, state) 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 diff --git a/lua/pdfview/search.lua b/lua/pdfview/search.lua index c97773d..4d521e6 100644 --- a/lua/pdfview/search.lua +++ b/lua/pdfview/search.lua @@ -1,46 +1,41 @@ -local Parser = require "pdfview.parser" -local M = {} +local Util = require "pdfview.utils" --- Split full pdftotext output into pages (pdftotext inserts \f between pages) ----@param full_text string -local function split_pages(full_text) - local pages = {} - for page in (full_text .. "\f"):gmatch "(.-)\f" do - table.insert(pages, page) - end - return pages -end +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, opts) +function M.find_matches(pdf_path, query, state, opts) opts = opts or {} - local full_text = Parser.extract_text(pdf_path) -- no start/end = whole doc - if not full_text then - return {} + 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 pages = split_pages(full_text) local matches = {} local pattern = opts.case_sensitive and query or query:lower() - for page_num, page_text in ipairs(pages) do - local line_num = 0 - for line in (page_text .. "\n"):gmatch "(.-)\n" do - line_num = line_num + 1 + 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) -- plain-text find + local col = haystack:find(pattern, 1, true) if col then - local txt_trim = vim.trim(line) table.insert(matches, { - filename = pdf_path, page = page_num, line = line_num, col = col, - text = txt_trim, - text_line = string.format("[p.%d] %s", page_num, txt_trim), + text = vim.trim(line), }) end end From ef71ad2f505676d24f0306553b4f84d640096ee6 Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Wed, 15 Jul 2026 02:43:56 +0700 Subject: [PATCH 17/19] feat: add 'q' keybinding to close search indicator --- lua/pdfview/keymaps.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lua/pdfview/keymaps.lua b/lua/pdfview/keymaps.lua index 97960d3..e2a8f50 100644 --- a/lua/pdfview/keymaps.lua +++ b/lua/pdfview/keymaps.lua @@ -66,6 +66,12 @@ local function search(state, step, bufnr) 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 From e1e786a0b41e0d139d09c8d3a18546c575d794de Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Wed, 15 Jul 2026 03:06:19 +0700 Subject: [PATCH 18/19] ref(ui): use dynamic padding and remove a hardcoded value --- lua/pdfview/ui.lua | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/lua/pdfview/ui.lua b/lua/pdfview/ui.lua index 10f284d..0ab9559 100644 --- a/lua/pdfview/ui.lua +++ b/lua/pdfview/ui.lua @@ -123,7 +123,8 @@ end ---@param buf integer ---@param lines string[] -local function apply_higlights(buf, lines) +---@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) @@ -139,7 +140,7 @@ local function apply_higlights(buf, lines) goto continue end - local item_field_width = 20 + 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 @@ -217,17 +218,32 @@ function view.menu(cfg) 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 + + local padding_display_lines = 0 for i, item in ipairs(lines) do local shortcut = resolve_shortcut(item) - table.insert(display_lines, string.format(" %s %-20s %s", "●", item, shortcut)) + 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 + width = math.min(width, padding_display_lines + 2) height = math.min(#display_lines + 1, height) ---@type WinCfg @@ -269,7 +285,7 @@ function view.menu(cfg) 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) + apply_higlights(main_buf, display_lines, padding_line) local opts = { buf = main_buf, win = main_win, hval = hval } view.setup_ui_mappings(opts) From 94c6494035910cee601a6108df1e1e34949bc915 Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Wed, 15 Jul 2026 03:25:54 +0700 Subject: [PATCH 19/19] ref: improve command naming and menu organization --- lua/pdfview/init.lua | 8 +++++--- lua/pdfview/ui.lua | 42 ++++++++++++++++++++++-------------------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/lua/pdfview/init.lua b/lua/pdfview/init.lua index c0598d2..296a11f 100644 --- a/lua/pdfview/init.lua +++ b/lua/pdfview/init.lua @@ -64,7 +64,7 @@ function M.select_bookmark() end) end -function M.select_search() +function M.select_text_search() local path = Config.defaults.path if not Util.is_dir(path) then return @@ -73,7 +73,7 @@ function M.select_search() picker.select(Config.defaults.picker or "default", "search", path, nil) end -function M.last_bookmark() +function M.open_from_last_bookmark() local pdf_bookmarks = Util.get_pdf_bookmarks() if not pdf_bookmarks then return @@ -175,6 +175,8 @@ function M.open(pdf_path, opts) 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 @@ -224,7 +226,7 @@ end ---@param pdf_path? string ---@param query? string -function M.search_text(pdf_path, query) +function M.text_search(pdf_path, query) if pdf_path and query then search_to(pdf_path, query) end diff --git a/lua/pdfview/ui.lua b/lua/pdfview/ui.lua index 0ab9559..ea85906 100644 --- a/lua/pdfview/ui.lua +++ b/lua/pdfview/ui.lua @@ -167,35 +167,21 @@ end function view.menu(cfg) local win_buf = vim.api.nvim_create_buf(false, true) - local editor_size = get_editor_size() - local height = math.floor(editor_size.height * 20 / 100) - local width = math.floor(editor_size.width * 20 / 100) - - ---@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) - local lines = { "Select file pdf", "Select bookmark", - -- "History", } if vim.bo.filetype ~= "pdfview" then - lines[#lines + 1] = "Last bookmark" + 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] = "Search text" - lines[#lines + 1] = "Select search" + 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" @@ -226,6 +212,10 @@ function view.menu(cfg) 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) @@ -243,8 +233,20 @@ function view.menu(cfg) end end - width = math.min(width, padding_display_lines + 2) - height = math.min(#display_lines + 1, height) + 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 = {