From c8371812890731319f7f3b5cf681ac63b5ccf023 Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Thu, 9 Jul 2026 06:55:53 +0700 Subject: [PATCH 01/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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 = { From 58c226bb395a2a4890d3131175ddef5536a073b6 Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Sun, 26 Jul 2026 02:53:25 +0700 Subject: [PATCH 20/26] ref(pdfview): update text search indicator config and highlight setup --- lua/pdfview/config.lua | 28 +++++++++++--- lua/pdfview/types.lua | 28 ++++++++++++-- lua/pdfview/utils.lua | 83 ++++++++++++++++++++++++++++++++---------- 3 files changed, 111 insertions(+), 28 deletions(-) diff --git a/lua/pdfview/config.lua b/lua/pdfview/config.lua index 4abd246..4d86f58 100644 --- a/lua/pdfview/config.lua +++ b/lua/pdfview/config.lua @@ -11,11 +11,29 @@ M.defaults = { window = { winhighlight = nil, text_search_indicator = { - sep_front = "", - sep_back = "", - hl = "WarningMsg", - attr = "fg", - icon = " ", + search = { + fg = { hl = "Normal", attr = "bg" }, + bg = { hl = "Function", attr = "fg" }, + bold = true, + }, + icon = { + text = " ", + fg = { hl = "Normal", attr = "bg" }, + bg = { hl = "WarningMsg", attr = "fg" }, + }, + + sep = { + back = "", + front = "", + fg = { hl = "Normal", attr = "bg" }, + bg = { hl = "WarningMsg", attr = "fg" }, + }, + + text = { + fg = { hl = "Normal", attr = "bg" }, + bg = { hl = "WarningMsg", attr = "fg" }, + bold = true, + }, }, }, keymaps = { diff --git a/lua/pdfview/types.lua b/lua/pdfview/types.lua index 4e6878b..abea0a3 100644 --- a/lua/pdfview/types.lua +++ b/lua/pdfview/types.lua @@ -17,9 +17,19 @@ ---@field next_search_text string ---@field prev_search_text string ----@class PDFviewPopup +---@class PDFviewPopupTextSearchHl +---@field hl string, +---@field attr "fg"|"bg" + +---@class PDFviewPopupTextSearchIndicator +---@field sep { back: string, front: string, fg: PDFviewPopupTextSearchHl, bg: PDFviewPopupTextSearchHl } +---@field icon { text: string, fg: PDFviewPopupTextSearchHl, bg: PDFviewPopupTextSearchHl } +---@field text { fg: PDFviewPopupTextSearchHl, bg: PDFviewPopupTextSearchHl, bold?: boolean } +---@field search { fg: PDFviewPopupTextSearchHl, bg: PDFviewPopupTextSearchHl, bold?: boolean } + +---@class PDFviewWindow ---@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} +---@field text_search_indicator PDFviewPopupTextSearchIndicator ---@class PDFviewBookmarkSaved ---@field last_page integer @@ -28,15 +38,17 @@ ---@field created_at number ---@field text_path string ---@field text_page string +---@field pages? table ---@class PDFviewCfg ---@field path string ---@field save string ---@field picker string ----@field window PDFviewPopup +---@field window PDFviewWindow ---@field pdf_path string|nil ---@field keymaps PDFviewKeymaps ---@field open? {cb?:function} +---@field save_last_open? string ---@field ui? {cb?:function, menu?: table} ---@field group? string @@ -52,16 +64,24 @@ ---@field current_query string ---@field cache table +---@class PDFviewStateO +---@field pages table +---@field page_offset integer +---@field total_pages integer +---@field current_page integer + ---@class PDFviewStateRender ---@field current_page integer ---@field total_pages integer ---@field pdf_path string ---@field filetype string ---@field pages table +---@field o table ---@field buf integer|nil ---@field ns_id integer|nil ---@field ns_search_id integer|nil ---@field page_offset integer ----@field win? integer ---@field win_status_search_indicator? integer +---@field orginal_text? string +---@field win? integer ---@field search? PDFviewMatchQuery diff --git a/lua/pdfview/utils.lua b/lua/pdfview/utils.lua index 2343ddb..605c92d 100644 --- a/lua/pdfview/utils.lua +++ b/lua/pdfview/utils.lua @@ -1,4 +1,5 @@ local Plenary_path = require "plenary.path" +local Cfg_win_text_s = require("pdfview.config").defaults.window.text_search_indicator local M = {} @@ -106,6 +107,11 @@ function M.create_dir(path) end end +function M.get_mtime(filepath) + local stat = vim.uv.fs_stat(filepath) + return stat and stat.mtime.sec or nil +end + function M.system_command(cmds) vim.system(cmds, { detach = true }, function(res) if res.code ~= 0 then @@ -228,39 +234,75 @@ function M.get_pdf_bookmarks() return dofile(file_saved) or {} end +local is_hl_text_search + +local function setup_text_search_hl() + if not is_hl_text_search then + local sep_hl_fg = (Cfg_win_text_s and Cfg_win_text_s.sep) and Cfg_win_text_s.sep.fg.hl or "WarningMsg" + local sep_hl_fg_attr = (Cfg_win_text_s and Cfg_win_text_s.sep) and Cfg_win_text_s.sep.fg.attr or "fg" + local sep_hl_bg = (Cfg_win_text_s and Cfg_win_text_s.sep) and Cfg_win_text_s.sep.bg.hl or "Normal" + local sep_hl_bg_attr = (Cfg_win_text_s and Cfg_win_text_s.sep) and Cfg_win_text_s.sep.bg.attr or "bg" + + local text_hl_fg = (Cfg_win_text_s and Cfg_win_text_s.text) and Cfg_win_text_s.text.fg.hl or "Normal" + local text_hl_fg_attr = (Cfg_win_text_s and Cfg_win_text_s.text) and Cfg_win_text_s.text.fg.attr or "bg" + local text_hl_bg = (Cfg_win_text_s and Cfg_win_text_s.text) and Cfg_win_text_s.text.bg.hl or "bg" + local text_hl_bg_attr = (Cfg_win_text_s and Cfg_win_text_s.text) and Cfg_win_text_s.text.bg.attr or "bg" + local text_hl_bold = (Cfg_win_text_s and Cfg_win_text_s.text) and Cfg_win_text_s.text.bold or false + + local icon_hl_fg = (Cfg_win_text_s and Cfg_win_text_s.icon) and Cfg_win_text_s.icon.fg.hl or "WarningMsg" + local icon_hl_fg_attr = (Cfg_win_text_s and Cfg_win_text_s.icon) and Cfg_win_text_s.icon.fg.attr or "fg" + local icon_hl_bg = (Cfg_win_text_s and Cfg_win_text_s.icon) and Cfg_win_text_s.icon.bg.hl or "Normal" + local icon_hl_bg_attr = (Cfg_win_text_s and Cfg_win_text_s.icon) and Cfg_win_text_s.icon.bg.attr or "bg" + + local sep_bg = get_hl(sep_hl_fg)[sep_hl_fg_attr] + local sep_fg = get_hl(sep_hl_bg)[sep_hl_bg_attr] + + local text_bg = get_hl(text_hl_bg)[text_hl_bg_attr] + local text_fg = get_hl(text_hl_fg)[text_hl_fg_attr] + + local icon_bg = get_hl(icon_hl_bg)[icon_hl_bg_attr] + local icon_fg = get_hl(icon_hl_fg)[icon_hl_fg_attr] + + vim.api.nvim_set_hl(0, "PDFviewStatusSearchSep", { fg = sep_fg, bg = sep_bg }) + vim.api.nvim_set_hl(0, "PDFviewStatusSearchIcon", { fg = icon_fg, bg = icon_bg }) + vim.api.nvim_set_hl(0, "PDFviewStatusSearchNormal", { fg = text_fg, bg = text_bg, bold = text_hl_bold }) + + local search_hl_bg = (Cfg_win_text_s and Cfg_win_text_s.search) and Cfg_win_text_s.search.bg.hl or "WarningMsg" + local search_hl_bg_attr = (Cfg_win_text_s and Cfg_win_text_s.search) and Cfg_win_text_s.search.bg.attr or "fg" + local search_hl_fg = (Cfg_win_text_s and Cfg_win_text_s.search) and Cfg_win_text_s.search.fg.hl or "Normal" + local search_hl_fg_attr = (Cfg_win_text_s and Cfg_win_text_s.search) and Cfg_win_text_s.search.fg.attr or "bg" + local search_hl_bold = (Cfg_win_text_s and Cfg_win_text_s.search) and Cfg_win_text_s.search.bold or false + + local search_fg = get_hl(search_hl_fg)[search_hl_fg_attr] + local search_bg = get_hl(search_hl_bg)[search_hl_bg_attr] + + vim.api.nvim_set_hl(0, "PDFviewStatusTextSearch", { fg = search_fg, bg = search_bg, bold = search_hl_bold }) + end + + is_hl_text_search = true +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 + local sep_front = (Cfg_win_text_s and Cfg_win_text_s.sep) and Cfg_win_text_s.sep.front or "" + local sep_back = (Cfg_win_text_s and Cfg_win_text_s.sep) and Cfg_win_text_s.sep.back or "" + local icon = (Cfg_win_text_s and Cfg_win_text_s.icon) and Cfg_win_text_s.icon.text or " " -- 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", + '%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 = sep_back, hl = "PDFviewStatusSearchSep" }, { text = icon, hl = "PDFviewStatusSearchIcon" }, { text = " " .. text, hl = nil }, - { text = sepBack, hl = "PDFviewStatusSearchSep" }, + { text = sep_front, hl = "PDFviewStatusSearchSep" }, } local full_line = "" @@ -345,6 +387,8 @@ function M.__add_buf_highlight(item, state, idx_text_search, total_search_text) local line_text = vim.api.nvim_buf_get_lines(state.buf, target_line - 1, target_line, false)[1] or "" local end_col = #line_text + setup_text_search_hl() + if idx_text_search and total_search_text then local __opts = { buffer_line = bufline_count, @@ -356,10 +400,11 @@ function M.__add_buf_highlight(item, state, idx_text_search, total_search_text) end M.del_namespace(state.buf, state.ns_search_id) + local mark_id = M.set_extmark(state.buf, state.ns_search_id, target_line - 1, target_col, { end_row = target_line - 1, end_col = end_col, - hl_group = "Search", + hl_group = "PDFviewStatusTextSearch", }) if mark_id then From e65e395f6b7a2787e5650fddc9d570c8255deb14 Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Mon, 27 Jul 2026 07:23:57 +0700 Subject: [PATCH 21/26] feat(ui): implement 'show global keymaps' --- lua/pdfview/keymaps.lua | 15 +- lua/pdfview/types.lua | 3 + lua/pdfview/ui.lua | 334 +++++++++++++++++++++++++++++++--------- 3 files changed, 281 insertions(+), 71 deletions(-) diff --git a/lua/pdfview/keymaps.lua b/lua/pdfview/keymaps.lua index e2a8f50..e33964d 100644 --- a/lua/pdfview/keymaps.lua +++ b/lua/pdfview/keymaps.lua @@ -87,6 +87,8 @@ local function prev_search_text(state, bufnr) search(state, -1, bufnr) end +local _h_keys = {} + ---@param ctx vim.api.keyset.create_autocmd.callback_args ---@param state PDFviewStateRender local function setup_pdfview_ft_mappings(ctx, state) @@ -105,14 +107,22 @@ local function setup_pdfview_ft_mappings(ctx, state) { 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.search, fun = function() pdfview.text_search() end, desc = "search text", buf = bufnr }, + { key = keymaps.pick_search, fun = function() pdfview.select_text_search() end, desc = "select search result", buf = bufnr }, { key = keymaps.next_search_text, fun = function() next_search_text(state, bufnr) end, desc = "next search result", buf = bufnr }, { key = keymaps.prev_search_text, fun = function() prev_search_text(state, bufnr) end, desc = "previous search result", buf = bufnr }, + + { key = keymaps.show_helps, fun = function() require("pdfview.ui").call("show_keymap_helps", Config.defaults) end, desc = "show helps", buf = bufnr }, } M.append_to(_keys) + if not Config.defaults.show_helps then + Config.defaults.show_helps = {} + end + + Config.defaults.show_helps = _h_keys + local augroup = Util.create_augroup_name("SearchCleanup_" .. state.buf) vim.api.nvim_create_autocmd({ "BufWipeout", "BufUnload" }, { group = augroup, @@ -148,6 +158,7 @@ end function M.append_to(tbl_keys) for _, k in pairs(tbl_keys) do local keys = type(k.key) == "string" and { k.key } or k.key + table.insert(_h_keys, { key = k.key, desc = k.desc, type = k.type and k.type or "global" }) for _, key in ipairs(keys) do if type(k.fun) == "function" then vim.keymap.set("n", key, k.fun, { diff --git a/lua/pdfview/types.lua b/lua/pdfview/types.lua index abea0a3..ae627a4 100644 --- a/lua/pdfview/types.lua +++ b/lua/pdfview/types.lua @@ -3,6 +3,7 @@ ---@field fun string|function ---@field desc string ---@field buf? integer +---@field type? "menu"|"global" ---@class PDFviewKeymaps ---@field go_to_page string @@ -16,6 +17,7 @@ ---@field pick_search string ---@field next_search_text string ---@field prev_search_text string +---@field show_helps string ---@class PDFviewPopupTextSearchHl ---@field hl string, @@ -51,6 +53,7 @@ ---@field save_last_open? string ---@field ui? {cb?:function, menu?: table} ---@field group? string +---@field show_helps? {key: string|table, desc: string, type?: "menu"|"global"}[] ---@class PDFviewMatch ---@field page integer diff --git a/lua/pdfview/ui.lua b/lua/pdfview/ui.lua index ea85906..68f4445 100644 --- a/lua/pdfview/ui.lua +++ b/lua/pdfview/ui.lua @@ -4,6 +4,7 @@ local M = {} local _ui = { menu_filetype = "pdfview_menu", + menu_show_filetype = "pdfview_menu_help", ns = "PDFviewU", } @@ -37,17 +38,145 @@ local function new_open(win_opts, lines) return opts.buf, win end -local view = {} +---@param buf integer +---@param lines string[] +---@param padding_line integer +local function apply_menu_higlights(buf, lines, padding_line) + local ns = vim.api.nvim_create_namespace(_ui.ns) + Util.del_namespace(buf, ns) + + for lnum, line in ipairs(lines) do + if not line or line == "" then + goto continue + end + + local row = lnum - 1 + + local _, bullet_end = line:find("●", 1, true) + if not bullet_end then + goto continue + end + + local item_field_width = padding_line + local item_start = bullet_end + 2 + local item_end = item_start + item_field_width + local shortcut_start = item_end + 1 + + -- Highlight name item + Util.set_extmark(buf, ns, row, item_start, { + end_col = item_end, + hl_group = "Function", + priority = 8, + }) + + -- Highlight shortcut + Util.set_extmark(buf, ns, row, shortcut_start, { + end_col = #line, + hl_group = "Boolean", + priority = 8, + }) + + ::continue:: + end +end + +---@param buf integer +---@param lines string[] +local function apply_show_menu_highlights(buf, lines) + local ns = vim.api.nvim_create_namespace(_ui.ns) + Util.del_namespace(buf, ns) + + local headers = { + [1] = "Comment", -- "Press q or ..." + [3] = "Title", -- "Global keymaps:" + } + + for lnum, line in ipairs(lines) do + if not line or line == "" then + goto continue + end + + local row = lnum - 1 + + -- highlight header lines + if headers[lnum] then + Util.set_extmark(buf, ns, row, 0, { + end_col = #line, + hl_group = headers[lnum], + priority = 8, + }) + goto continue + end + + if not line:match "^ %S" then + goto continue + end + + -- find key start (first non-space) + local key_start = line:find "%S" + if not key_start then + goto continue + end + + -- find desc_start by scanning for double-space gap after key + local key_end = key_start + local desc_start = nil + local i = key_start + + while i <= #line do + if line:sub(i, i + 1) == " " then + local j = i -- found gap, skip all spaces to find desc start + while j <= #line and line:sub(j, j) == " " do + j = j + 1 + end + if j <= #line then + desc_start = j + end + break + end + key_end = i + i = i + 1 + end + + if not desc_start then + goto continue + end + + Util.set_extmark(buf, ns, row, key_start - 1, { + end_col = key_end, + hl_group = "Function", + priority = 8, + }) + + Util.set_extmark(buf, ns, row, desc_start - 1, { + end_col = #line, + hl_group = "Normal", + priority = 8, + }) + + ::continue:: + end +end + +---@param editor_size {width: integer, height: integer} +---@param height_editor integer +---@param width_editor integer +local function get_center_col_row(editor_size, 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 view = { keys = {} } ---@param opts {buf: integer, win:integer, hval: table} function view.setup_ui_mappings(opts) local keymaps = require "pdfview.keymaps" local pdfview = require "pdfview" + local cfg = require("pdfview.config").defaults ---@type PDFviewKeySpec[] - local keys = {} - - keys = { + view.keys = { -- Enter { key = "", @@ -66,6 +195,7 @@ function view.setup_ui_mappings(opts) end, desc = "select item", buf = opts.buf, + type = "menu", }, -- Quit { @@ -76,31 +206,45 @@ function view.setup_ui_mappings(opts) end, desc = "quit", buf = opts.buf, + type = "menu", }, -- Navigate { - key = { "k", "", "" }, + 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", + desc = "next item", buf = opts.buf, + type = "menu", }, { - key = { "j", "", "" }, + 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", + desc = "prev item", buf = opts.buf, + type = "menu", + }, + + { + key = cfg.keymaps.show_helps, + fun = function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true) + view.show_keymap_helps(cfg) + end, + desc = "show helps", + buf = opts.buf, + type = "menu", }, } for _, h in pairs(opts.hval) do - keys[#keys + 1] = { + view.keys[#view.keys + 1] = { key = h.shortcut, fun = function() vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true) @@ -113,54 +257,13 @@ function view.setup_ui_mappings(opts) end end) end, - desc = "select item", + desc = "shortcut key: " .. h.method, buf = opts.buf, + type = "menu", } end - keymaps.append_to(keys) -end - ----@param buf integer ----@param lines string[] ----@param padding_line integer -local function apply_higlights(buf, lines, padding_line) - local ns = vim.api.nvim_create_namespace(_ui.ns) - Util.del_namespace(buf, ns) - - for lnum, line in ipairs(lines) do - if not line or line == "" then - goto continue - end - - local row = lnum - 1 - - local _, bullet_end = line:find("●", 1, true) - if not bullet_end then - goto continue - end - - local item_field_width = padding_line - local item_start = bullet_end + 2 - local item_end = item_start + item_field_width - local shortcut_start = item_end + 1 - - -- Highlight name item - Util.set_extmark(buf, ns, row, item_start, { - end_col = item_end, - hl_group = "Function", - priority = 8, - }) - - -- Highlight shortcut - Util.set_extmark(buf, ns, row, shortcut_start, { - end_col = #line, - hl_group = "Boolean", - priority = 8, - }) - - ::continue:: - end + keymaps.append_to(view.keys) end ---@param cfg PDFviewCfg @@ -235,18 +338,15 @@ function view.menu(cfg) 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(editor_size, height, width) + local title_label = " / quit " + local title_footer = vim.bo.filetype == "pdfview" + and " " .. cfg.keymaps.show_helps .. " show globals · " .. title_label + or title_label - local col, row = get_center_col_row(height, width) + width = math.max(vim.fn.strdisplaywidth(title_footer), padding_display_lines + 4) ---@type WinCfg local wincfg = { @@ -262,7 +362,7 @@ function view.menu(cfg) border = "rounded", title = Util.format_title "menu", title_pos = "center", - footer = " / quit ", + footer = title_footer, footer_pos = "center", }, } @@ -275,11 +375,7 @@ function view.menu(cfg) 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,", + or "Normal:Error,NormalFloat:NormalFloat,FloatBorder:FloatBorder,FloatTitle:FloatTitle,", { win = main_win, scope = "local" } ) @@ -287,12 +383,112 @@ 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, padding_line) + apply_menu_higlights(main_buf, display_lines, padding_line) local opts = { buf = main_buf, win = main_win, hval = hval } view.setup_ui_mappings(opts) end +---@param cfg PDFviewCfg +function view.show_keymap_helps(cfg) + local key_show_helps = cfg.show_helps or {} + + if Util.is_blank(key_show_helps) then + return + end + + local function format_key(x) + if type(x) == "string" then + return x + end + local key_tbl = {} + for _, y in ipairs(x) do + key_tbl[#key_tbl + 1] = y + end + return table.concat(key_tbl, " ") + end + + local pad = 0 + for _, x in ipairs(key_show_helps) do + local key = format_key(x.key) + if key then + local str_w = vim.fn.strdisplaywidth(key) + if pad < str_w then + pad = str_w + 2 + end + end + end + + local display_lines = {} + local seen = {} + local pad_str = 0 + + for _, x in ipairs(key_show_helps) do + local key = format_key(x.key) + + if not seen[key .. " " .. x.desc] then + seen[key .. " " .. x.desc] = true + + if x.type == "global" then + local lines = string.format(" %-" .. pad + 10 .. "s %s", key, x.desc) + local str_w = vim.fn.strdisplaywidth(lines) + if pad_str < str_w then + pad_str = str_w + end + display_lines[#display_lines + 1] = lines + end + end + end + + table.insert(display_lines, 1, "Press q or to close this window.") + table.insert(display_lines, 2, "") + table.insert(display_lines, 3, "Global keymaps:") + + local editor_size = get_editor_size() + local height = math.min(#display_lines, math.floor(editor_size.height / 2)) + 1 + local width = math.min(pad_str, math.floor(editor_size.width / 2)) + 2 + local col, row = get_center_col_row(editor_size, height, width) + local title_str = "global keymaps" + + local wincfg = { + buf = vim.api.nvim_create_buf(false, true), + enter = true, + wincfg = { + relative = "editor", + width = width, + height = height, + row = row, + col = col, + style = "minimal", + border = "rounded", + title = Util.format_title(title_str), + title_pos = "center", + focusable = true, + noautocmd = true, + }, + } + + local buf, win = new_open(wincfg, {}) + vim.bo[buf].modifiable = true + + vim.api.nvim_buf_set_lines(buf, 0, -1, false, display_lines) + vim.bo[buf].filetype = _ui.menu_show_filetype + vim.bo[buf].modifiable = false + + apply_show_menu_highlights(buf, display_lines) + + vim.keymap.set("n", "", function() + if win and vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + end) + vim.keymap.set("n", "q", function() + if win and vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + end) +end + ---@param cfg PDFviewCfg function M.call(call_name, cfg) if not view[call_name] then From 602722c825903aa618338a1ad92937a4bd26a3ff Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Mon, 27 Jul 2026 07:27:09 +0700 Subject: [PATCH 22/26] feat: add mtime-based cache, last-open restore, and bookmark data restructure Bookmark storage is restructured from a flat list into a table with two fields: `items` (list of PDFviewBookmarkSavedData) and `__o` (per-pdf cached pages + mtime keyed by pdf_path). This allows pages to be reused across sessions without re-extracting text on every open. Renderer gains `state.o`,a per-pdf cache table populated by `update_state_o()` after every `display_text()` call. On subsequent opens, cached pages are passed directly to `display_text()` and mtime is checked against the current file; a mismatch triggers re-extraction. `get_mtime()` is added to utils for this purpose. --- lua/pdfview/config.lua | 3 +- lua/pdfview/init.lua | 88 +++++++++++++++++++++++++------ lua/pdfview/keymaps.lua | 65 ++++++++++++++++++----- lua/pdfview/pickers/default.lua | 49 +++++++++++------ lua/pdfview/pickers/fzf-lua.lua | 58 +++++++++++++------- lua/pdfview/pickers/snacks.lua | 50 +++++++++++------- lua/pdfview/pickers/telescope.lua | 57 +++++++++++++------- lua/pdfview/pickers/utils.lua | 14 ++--- lua/pdfview/renderer.lua | 47 +++++++++++++++-- lua/pdfview/search.lua | 2 + lua/pdfview/types.lua | 7 ++- lua/pdfview/utils.lua | 8 +-- 12 files changed, 332 insertions(+), 116 deletions(-) diff --git a/lua/pdfview/config.lua b/lua/pdfview/config.lua index 4d86f58..b309437 100644 --- a/lua/pdfview/config.lua +++ b/lua/pdfview/config.lua @@ -3,7 +3,7 @@ local M = {} ---@type PDFviewCfg M.defaults = { path = vim.fn.stdpath "config", - save = vim.fn.stdpath "state", + save = vim.fn.stdpath "cache", picker = "default", open = { cb = nil, @@ -48,6 +48,7 @@ M.defaults = { pick_search = "s", next_search_text = "", prev_search_text = "", + show_helps = "?", }, } diff --git a/lua/pdfview/init.lua b/lua/pdfview/init.lua index 296a11f..68cf357 100644 --- a/lua/pdfview/init.lua +++ b/lua/pdfview/init.lua @@ -13,7 +13,14 @@ function M.setup(opts) Config.update_settings(opts) Config.defaults.group = "PDFview" - Config.defaults.save = Config.defaults.save .. "/pdfview.lua" + local save_folder = Config.defaults.save .. "/pdfview" + + if not Util.is_dir(save_folder) then + Util.create_dir(save_folder) + end + + Config.defaults.save = save_folder .. "/pdfview.lua" + Config.defaults.save_last_open = save_folder .. "/pdfview_lastopen.lua" Util.clear_autocmd_group(Config.defaults.group) local group = vim.api.nvim_create_augroup(Config.defaults.group, { clear = true }) @@ -74,19 +81,49 @@ function M.select_text_search() end function M.open_from_last_bookmark() - local pdf_bookmarks = Util.get_pdf_bookmarks() - if not pdf_bookmarks then + local pdf_bookmark = Util.get_pdf_bookmarks() + if not pdf_bookmark or Util.is_blank(pdf_bookmark.items) then + Util.warn "No saved pdf bookmarks found." + return + end + + local opts = pdf_bookmark.items[1] + if not opts then return end - local opts = pdf_bookmarks[1] - Config.defaults.pdf_path = opts.pdf_path + local pdf_path = opts.pdf_path + Config.defaults.pdf_path = pdf_path + local pages = pdf_bookmark.__o[pdf_path] + if pages then + opts.pages = pages["pages"] + end + M.open(opts.pdf_path, opts) ensure_callback(opts.pdf_path) end -function M.history() - Util.not_implemented_yet() +function M.open_last() + local file_saved = Config.defaults.save_last_open + if not Util.is_file(file_saved) then + Util.warn "No saved last opened file was found. Please open a PDF first." + return + end + + local ok, last_open = pcall(dofile, file_saved) + + if not ok or type(last_open) ~= "table" or Util.is_blank(last_open.pdf_path) then + Util.warn "Failed to read last opened PDF data." + return + end + + Config.defaults.pdf_path = last_open.pdf_path + M.open(last_open.pdf_path, { last_page = last_open.last_page, pages = last_open.pages }) + + local state = renderer.get() + state.pages = last_open.pages or {} + + ensure_callback(last_open.pdf_path) end function M.menu() @@ -119,7 +156,7 @@ function M.go_to(page_num, state, notify) local num = tonumber(input) if not num then - Util.warn("go_to", "not a number `" .. input .. "`") + Util.warn("go_to", "Not a number `" .. input .. "`") return end @@ -153,7 +190,7 @@ local last_open_pdf -- Function to open the full PDF text (runs when PDF is selected) ---@param pdf_path string ----@param opts? table +---@param opts? {pdf_path: string, last_page: integer, pages: table} function M.open(pdf_path, opts) opts = opts or {} @@ -170,14 +207,33 @@ function M.open(pdf_path, opts) return end - last_open_pdf = opts.pdf_path + if not opts.pages then + local get_o_pdf_path = renderer.get().o[pdf_path] + if get_o_pdf_path then + opts.pages = get_o_pdf_path["pages"] + end + end - 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 .. "`") + local loaded = false + + if opts.pages and not Util.is_blank(opts.pages) then + renderer.display_text(nil, opts.last_page, opts.pages) + loaded = true + else + local text = parser.extract_text(opts.pdf_path) + if text then + renderer.display_text(text, opts.last_page) + loaded = true + end + end + + if not loaded then + return end + + last_open_pdf = opts.pdf_path + + Util.info(("Loaded PDF: `%s`"):format(vim.fn.fnamemodify(opts.pdf_path, ":~"))) end ---@param pdf_path string @@ -194,7 +250,7 @@ local function search_to(pdf_path, query, state) end if #matches == 0 then - Util.warn("no matches for '" .. query .. "'") + Util.warn("No matches for '" .. query .. "'") return end diff --git a/lua/pdfview/keymaps.lua b/lua/pdfview/keymaps.lua index e33964d..dac1151 100644 --- a/lua/pdfview/keymaps.lua +++ b/lua/pdfview/keymaps.lua @@ -5,20 +5,23 @@ local renderer = require "pdfview.renderer" local M = {} ---@param state PDFviewStateRender -local function save(state) - local file_saved = Config.defaults.save +---@param is_last_open? boolean +local function save(state, is_last_open) + is_last_open = is_last_open or false + local file_saved = is_last_open and Config.defaults.save_last_open or Config.defaults.save + if not Util.is_file(file_saved) then Util.create_file(file_saved) end - local pdf_bookmarks = Util.get_pdf_bookmarks() - if not pdf_bookmarks then + local pdf_bookmark = Util.get_pdf_bookmarks() + if not pdf_bookmark then return end local inserted_at = os.time() - ---@type PDFviewBookmarkSaved + ---@type PDFviewBookmarkSavedData local _data = { last_page = state.current_page, real_page = state.current_page + (state.page_offset or 0), @@ -29,13 +32,38 @@ local function save(state) text_path = vim.fn.fnamemodify(Config.defaults.pdf_path, ":~"), } - pdf_bookmarks[#pdf_bookmarks + 1] = _data + if is_last_open then + -- local pages = state.pages + -- if Util.is_blank(pages) then + -- local full_text = require("pdfview.parser").extract_text(Config.defaults.pdf_path) + -- pages = full_text and renderer.paginate_text(full_text) or {} + -- end + + _data.pages = state.pages or {} + Util.save_table_to_file(_data, file_saved) + return + end + + if not pdf_bookmark.__o then + pdf_bookmark.__o = {} + end + + local tbl_o = renderer.get().o[Config.defaults.pdf_path] + if tbl_o and not pdf_bookmark.__o[Config.defaults.pdf_path] then + pdf_bookmark.__o[Config.defaults.pdf_path] = tbl_o + end + + if not pdf_bookmark.items then + pdf_bookmark.items = {} + end + table.insert(pdf_bookmark.items, _data) + -- pdf_bookmark.items[#pdf_bookmark.items + 1] = _data - table.sort(pdf_bookmarks, function(a, b) + table.sort(pdf_bookmark, 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.save_table_to_file(pdf_bookmark, file_saved) Util.info("bookmark", string.format("Saved: %s · %s", _data.text_page, _data.text_path)) end @@ -130,12 +158,23 @@ local function setup_pdfview_ft_mappings(ctx, state) once = true, callback = function() if vim.api.nvim_buf_is_valid(bufnr) then - if state.search then - state.search = nil - end - if state.pages then - state.pages = nil + 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 }) + pcall(vim.keymap.del, "n", "q", { buffer = bufnr }) end + + save(state, true) + + -- FIX: temporarily commented out. If triggered between the two events above, + -- `pages` can become `nil`, causing `save()` to run twice and overwrite the + -- existing data with `nil`. + -- if state.search then + -- state.search = nil + -- end + -- if state.pages then + -- state.pages = nil + -- end end end, }) diff --git a/lua/pdfview/pickers/default.lua b/lua/pdfview/pickers/default.lua index e7c2172..8b10182 100644 --- a/lua/pdfview/pickers/default.lua +++ b/lua/pdfview/pickers/default.lua @@ -26,12 +26,13 @@ end ---@param path string ---@param cb function function M.bookmark(path, cb) - local pdf_bookmarks = Util.get_pdf_bookmarks() - if not pdf_bookmarks then + local pdf_bookmark = Util.get_pdf_bookmarks() + if not pdf_bookmark or Util.is_blank(pdf_bookmark.items) then + Util.warn "No saved pdf bookmarks found." return end - local contents = UtilPicker.bookmark_contents(pdf_bookmarks) + local contents = UtilPicker.bookmark_contents(pdf_bookmark.items) if Util.is_blank(contents) then return end @@ -42,25 +43,37 @@ function M.bookmark(path, cb) end local sel = vim.split(selection, "·") + if not sel then + return + end 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]) + for i, bookmark_item in pairs(pdf_bookmark.items) do + if bookmark_item.text_page == sel_page_num and bookmark_item.text_path == sel_pdf_path then + local sel_pdf_bookmark = pdf_bookmark.items[i] + if sel_pdf_bookmark then + local pdf_path = sel_pdf_bookmark.pdf_path + local pages = pdf_bookmark.__o[pdf_path] + if pages then + sel_pdf_bookmark.pages = pages["pages"] + end + return cb(sel_pdf_bookmark) + end end end end) end function M.delete_item_bookmark() - local pdf_bookmarks = Util.get_pdf_bookmarks() - if not pdf_bookmarks then + local pdf_bookmark = Util.get_pdf_bookmarks() + if not pdf_bookmark or Util.is_blank(pdf_bookmark.items) then + Util.warn "No saved pdf bookmarks found." return end - local contents = UtilPicker.bookmark_contents(pdf_bookmarks) + local contents = UtilPicker.bookmark_contents(pdf_bookmark.items) if Util.is_blank(contents) then return end @@ -71,21 +84,26 @@ function M.delete_item_bookmark() end local sel = vim.split(selection, "·") + if not sel then + return + end 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) + for i, bookmark_item in pairs(pdf_bookmark.items) do + if bookmark_item.text_page == sel_page_num and bookmark_item.text_path == sel_pdf_path then + table.remove(pdf_bookmark.items, i) - table.sort(pdf_bookmarks, function(a, b) + table.sort(pdf_bookmark.items, 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.") + Util.save_table_to_file(pdf_bookmark, file_saved) + + local filename = vim.fs.basename(bookmark_item.text_path) + Util.info(string.format("Removed bookmark page %s in `%s`.", bookmark_item.text_page, filename)) return end end @@ -114,6 +132,7 @@ function M.search() end local sel = selection + local item = seen[vim.trim(sel)] if not item then return diff --git a/lua/pdfview/pickers/fzf-lua.lua b/lua/pdfview/pickers/fzf-lua.lua index 75c39bb..82c639d 100644 --- a/lua/pdfview/pickers/fzf-lua.lua +++ b/lua/pdfview/pickers/fzf-lua.lua @@ -16,7 +16,7 @@ local function setup_fzflua() 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)" + Util.error("fzf-lua", "This extension requires ibhagwan/fzf-lua (https://github.com/ibhagwan/fzf-lua)") silent_notify = true return end @@ -40,6 +40,9 @@ function Mapping.default(path, cb) end local sel = selection[1] + if not sel then + return + end local pdf_path = path .. "/" .. sel pdf_path = vim.fs.normalize(pdf_path) @@ -47,50 +50,66 @@ function Mapping.default(path, cb) end end ----@param pdf_bookmarks PDFviewBookmarkSaved[] +---@param pdf_bookmark PDFviewBookmarkSaved ---@param cb function -function Mapping.default_bookmark(pdf_bookmarks, cb) +function Mapping.default_bookmark(pdf_bookmark, cb) return function(selection) if not selection then return end local sel = vim.split(selection[1], "·") + if not sel then + return + end 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]) + for i, bookmark_item in pairs(pdf_bookmark.items) do + if bookmark_item.text_page == sel_page_num and bookmark_item.text_path == sel_pdf_path then + local sel_pdf_bookmark = pdf_bookmark.items[i] + if sel_pdf_bookmark then + local pdf_path = sel_pdf_bookmark.pdf_path + local pages = pdf_bookmark.__o[pdf_path] + if pages then + sel_pdf_bookmark.pages = pages["pages"] + end + return cb(sel_pdf_bookmark) + end end end end end ----@param pdf_bookmarks PDFviewBookmarkSaved[] -function Mapping.delete_bookmark(pdf_bookmarks) +---@param pdf_bookmark PDFviewBookmarkSaved +function Mapping.delete_bookmark(pdf_bookmark) return function(selection) if not selection then return end local sel = vim.split(selection[1], "·") + if not sel then + return + end 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) + for i, bookmark_item in pairs(pdf_bookmark.items) do + if bookmark_item.text_page == sel_page_num and bookmark_item.text_path == sel_pdf_path then + table.remove(pdf_bookmark.items, i) - table.sort(pdf_bookmarks, function(a, b) + table.sort(pdf_bookmark.items, 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.") + Util.save_table_to_file(pdf_bookmark, file_saved) + + local filename = vim.fs.basename(bookmark_item.text_path) + Util.info(string.format("Removed bookmark page %s in `%s`.", bookmark_item.text_page, filename)) -- unplanned: should resume or exit? -- FzfLua.actions.resume() @@ -158,12 +177,13 @@ function M.bookmark(path, cb) return end - local pdf_bookmarks = Util.get_pdf_bookmarks() - if not pdf_bookmarks then + local pdf_bookmark = Util.get_pdf_bookmarks() + if not pdf_bookmark or Util.is_blank(pdf_bookmark.items) then + Util.warn "No saved pdf bookmarks found." return end - local contents = UtilPicker.bookmark_contents(pdf_bookmarks) + local contents = UtilPicker.bookmark_contents(pdf_bookmark.items) if Util.is_blank(contents) then return end @@ -174,8 +194,8 @@ function M.bookmark(path, cb) fzf_opts = { ["--header"] = [[ delete]] }, winopts = { title = Util.format_title "bookmarks", preview = { hidden = true } }, actions = { - ["default"] = Mapping.default_bookmark(pdf_bookmarks, cb), - ["ctrl-x"] = Mapping.delete_bookmark(pdf_bookmarks), + ["default"] = Mapping.default_bookmark(pdf_bookmark, cb), + ["ctrl-x"] = Mapping.delete_bookmark(pdf_bookmark), }, }) end diff --git a/lua/pdfview/pickers/snacks.lua b/lua/pdfview/pickers/snacks.lua index 7e1346b..67ded53 100644 --- a/lua/pdfview/pickers/snacks.lua +++ b/lua/pdfview/pickers/snacks.lua @@ -20,9 +20,9 @@ local M = {} local Mapping = {} ----@param pdf_bookmarks PDFviewBookmarkSaved[] +---@param pdf_bookmark PDFviewBookmarkSaved ---@param cb function -function Mapping.default_bookmark(pdf_bookmarks, cb, selection) +function Mapping.default_bookmark(pdf_bookmark, cb, selection) if not selection then return end @@ -33,36 +33,49 @@ function Mapping.default_bookmark(pdf_bookmarks, cb, 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]) + for i, bookmark_item in pairs(pdf_bookmark.items) do + if bookmark_item.text_page == sel_page_num and bookmark_item.text_path == sel_pdf_path then + local sel_pdf_bookmark = pdf_bookmark.items[i] + if sel_pdf_bookmark then + local pdf_path = sel_pdf_bookmark.pdf_path + local pages = pdf_bookmark.__o[pdf_path] + if pages then + sel_pdf_bookmark.pages = pages["pages"] + end + return cb(sel_pdf_bookmark) + end end end end ----@param pdf_bookmarks PDFviewBookmarkSaved[] +---@param pdf_bookmark PDFviewBookmarkSaved ---@param selection string -function Mapping.delete_bookmark(pdf_bookmarks, selection) +function Mapping.delete_bookmark(pdf_bookmark, selection) if not selection then return end local sel = vim.split(selection, "·") + if not sel then + return + end 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) + for i, bookmark_item in pairs(pdf_bookmark.items) do + if bookmark_item.text_page == sel_page_num and bookmark_item.text_path == sel_pdf_path then + table.remove(pdf_bookmark.items, i) - table.sort(pdf_bookmarks, function(a, b) + table.sort(pdf_bookmark.items, 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.") + Util.save_table_to_file(pdf_bookmark, file_saved) + + local filename = vim.fs.basename(bookmark_item.text_path) + Util.info(string.format("Removed bookmark page %s in `%s`.", bookmark_item.text_page, filename)) return end end @@ -111,12 +124,13 @@ end ---@param path string ---@param cb function function M.bookmark(path, cb) - local pdf_bookmarks = Util.get_pdf_bookmarks() - if not pdf_bookmarks then + local pdf_bookmark = Util.get_pdf_bookmarks() + if not pdf_bookmark or Util.is_blank(pdf_bookmark.items) then + Util.warn "No saved pdf bookmarks found." return end - local contents = UtilPicker.bookmark_contents(pdf_bookmarks) + local contents = UtilPicker.bookmark_contents(pdf_bookmark.items) if Util.is_blank(contents) then return end @@ -136,7 +150,7 @@ function M.bookmark(path, cb) confirm = function(picker, item) picker:close() if item then - Mapping.default_bookmark(pdf_bookmarks, cb, item.text) + Mapping.default_bookmark(pdf_bookmark, cb, item.text) end end, actions = { @@ -144,7 +158,7 @@ function M.bookmark(path, cb) if not item then return end - Mapping.delete_bookmark(pdf_bookmarks, item.text) + Mapping.delete_bookmark(pdf_bookmark, item.text) picker:close() vim.schedule(function() M.bookmark(path, cb) diff --git a/lua/pdfview/pickers/telescope.lua b/lua/pdfview/pickers/telescope.lua index df3f8ac..448ad10 100644 --- a/lua/pdfview/pickers/telescope.lua +++ b/lua/pdfview/pickers/telescope.lua @@ -22,44 +22,64 @@ local pdf_previewer = previewers.new_buffer_previewer { local Mapping = {} ----@param pdf_bookmarks PDFviewBookmarkSaved[] +---@param pdf_bookmark PDFviewBookmarkSaved ---@param cb function ---@param entry string -function Mapping.default_bookmark(pdf_bookmarks, cb, entry) +function Mapping.default_bookmark(pdf_bookmark, cb, entry) if not entry then return end local sel = vim.split(entry, "·") + if not sel then + return + end 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]) + for i, bookmark_item in pairs(pdf_bookmark.items) do + if bookmark_item.text_page == sel_page_num and bookmark_item.text_path == sel_pdf_path then + local sel_pdf_bookmark = pdf_bookmark.items[i] + if sel_pdf_bookmark then + local pdf_path = sel_pdf_bookmark.pdf_path + local pages = pdf_bookmark.__o[pdf_path] + if pages then + sel_pdf_bookmark.pages = pages["pages"] + end + return cb(sel_pdf_bookmark) + end end end end ----@param pdf_bookmarks PDFviewBookmarkSaved[] +---@param pdf_bookmark PDFviewBookmarkSaved ---@param entry string -function Mapping.delete_bookmark_entry(pdf_bookmarks, entry) +function Mapping.delete_bookmark_entry(pdf_bookmark, entry) if not entry then return end local sel = vim.split(entry, "·") + if not sel then + return + end 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) + for i, bookmark_item in pairs(pdf_bookmark.items) do + if bookmark_item.text_page == sel_page_num and bookmark_item.text_path == sel_pdf_path then + table.remove(pdf_bookmark.items, i) + + table.sort(pdf_bookmark.items, 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_bookmark, file_saved) - Util.save_table_to_file(pdf_bookmarks, file_saved) - Util.info(_pdf.text_path .. " removed.") + local filename = vim.fs.basename(bookmark_item.text_path) + Util.info(string.format("Removed bookmark page %s in `%s`.", bookmark_item.text_page, filename)) return end end @@ -111,12 +131,13 @@ end ---@param path string ---@param cb function function M.bookmark(path, cb) - local pdf_bookmarks = Util.get_pdf_bookmarks() - if not pdf_bookmarks then + local pdf_bookmark = Util.get_pdf_bookmarks() + if not pdf_bookmark or Util.is_blank(pdf_bookmark.items) then + Util.warn "No saved pdf bookmarks found." return end - local contents = UtilPicker.bookmark_contents(pdf_bookmarks) + local contents = UtilPicker.bookmark_contents(pdf_bookmark.items) if Util.is_blank(contents) then return end @@ -140,7 +161,7 @@ function M.bookmark(path, cb) local entry = action_state.get_selected_entry() actions.close(prompt_bufnr) if entry then - Mapping.default_bookmark(pdf_bookmarks, cb, entry.value) + Mapping.default_bookmark(pdf_bookmark, cb, entry.value) end end) @@ -149,11 +170,11 @@ function M.bookmark(path, cb) if not entry then return end - Mapping.delete_bookmark_entry(pdf_bookmarks, entry.value) + Mapping.delete_bookmark_entry(pdf_bookmark, entry.value) -- Refresh list local current_picker = action_state.get_current_picker(prompt_bufnr) - local new_contents = UtilPicker.bookmark_contents(pdf_bookmarks) + local new_contents = UtilPicker.bookmark_contents(pdf_bookmark.items) current_picker:refresh( finders.new_table { results = new_contents, diff --git a/lua/pdfview/pickers/utils.lua b/lua/pdfview/pickers/utils.lua index 95fd4ce..980bab0 100644 --- a/lua/pdfview/pickers/utils.lua +++ b/lua/pdfview/pickers/utils.lua @@ -2,7 +2,7 @@ local Util = require "pdfview.utils" local M = {} ----@param pdf_bookmarks PDFviewBookmarkSaved[] +---@param pdf_bookmarks PDFviewBookmarkSavedData local padding = function(pdf_bookmarks) local _pad = 0 for _, x in ipairs(pdf_bookmarks) do @@ -19,21 +19,21 @@ function M.find_command(path) return { "find", path or ".", "-type", "f", "-name", "*.pdf" } end ----@param pdf_bookmarks PDFviewBookmarkSaved[] +---@param pdf_bookmarks_data PDFviewBookmarkSavedData ---@return string[] -function M.bookmark_contents(pdf_bookmarks) - if not pdf_bookmarks then +function M.bookmark_contents(pdf_bookmarks_data) + if not pdf_bookmarks_data then return {} end - if Util.is_blank(pdf_bookmarks) then + if Util.is_blank(pdf_bookmarks_data) then Util.warn "No saved PDF bookmarks found. Please create one first." return {} end - local _pad = padding(pdf_bookmarks) + local _pad = padding(pdf_bookmarks_data) local contents = {} - for _, _pdf in pairs(pdf_bookmarks) do + for _, _pdf in pairs(pdf_bookmarks_data) do contents[#contents + 1] = string.format("%-" .. _pad .. "s · %s", _pdf.text_page, _pdf.text_path) end return contents diff --git a/lua/pdfview/renderer.lua b/lua/pdfview/renderer.lua index 6d1a745..efc12cf 100644 --- a/lua/pdfview/renderer.lua +++ b/lua/pdfview/renderer.lua @@ -15,6 +15,7 @@ local state = { ns_id = nil, ns_search_id = nil, pages = {}, + o = {}, } -- Function to display the current page @@ -65,6 +66,7 @@ function M.update_page_info() end -- Function to split text into pages +---@return table function M.paginate_text(text) text = text:gsub("\f%s*$", "") @@ -76,13 +78,49 @@ function M.paginate_text(text) return pages end +---@param pdf_path? string +---@return PDFviewStateO +function M.update_state_o(pdf_path) + pdf_path = pdf_path or state.pdf_path + local pages = state.pages + local has_mtime_changed = false + + local current_mtime = Util.get_mtime(pdf_path) + + if state.o[pdf_path] then + local last_mtime = state.o[pdf_path]["mtime"] + if last_mtime ~= current_mtime then + has_mtime_changed = true + end + end + + -- check mtime here + if has_mtime_changed then + local parser = require "pdfview.parser" + local text = parser.extract_text(state.pdf_path) + pages = M.paginate_text(text) + end + + if not state.o[pdf_path] then + state.o[pdf_path] = { + pages = pages, + mtime = current_mtime, + page_offset = state.page_offset, + total_pages = state.total_pages, + current_page = state.current_page, + } + end + + return state.o[pdf_path] +end + -- Function to initialize the buffer and display the first page -function M.display_text(text, start_page, pdf_path) - if Config.defaults.pdf_path then - state.pdf_path = pdf_path +function M.display_text(text, start_page, pages, pdf_path) + if not pdf_path then + pdf_path = state.pdf_path end - state.pages = M.paginate_text(text) + state.pages = pages or M.paginate_text(text) state.total_pages = #state.pages if start_page then @@ -108,6 +146,7 @@ function M.display_text(text, start_page, pdf_path) state.ns_id = vim.api.nvim_create_namespace "PDFview" M.display_current_page() + M.update_state_o(state.pdf_path) if buf and vim.api.nvim_buf_is_valid(buf) then vim.api.nvim_set_current_buf(state.buf) diff --git a/lua/pdfview/search.lua b/lua/pdfview/search.lua index 4d521e6..dcc1b43 100644 --- a/lua/pdfview/search.lua +++ b/lua/pdfview/search.lua @@ -31,11 +31,13 @@ function M.find_matches(pdf_path, query, state, opts) local haystack = opts.case_sensitive and line or line:lower() local col = haystack:find(pattern, 1, true) if col then + local txt_trim = vim.trim(line) table.insert(matches, { page = page_num, line = line_num, col = col, text = vim.trim(line), + text_line = string.format("[p.%d] %s", page_num, txt_trim), }) end end diff --git a/lua/pdfview/types.lua b/lua/pdfview/types.lua index ae627a4..65f606a 100644 --- a/lua/pdfview/types.lua +++ b/lua/pdfview/types.lua @@ -33,7 +33,7 @@ ---@field winhighlight string|nil ---@field text_search_indicator PDFviewPopupTextSearchIndicator ----@class PDFviewBookmarkSaved +---@class PDFviewBookmarkSavedData ---@field last_page integer ---@field real_page integer ---@field pdf_path string @@ -42,6 +42,10 @@ ---@field text_page string ---@field pages? table +---@class PDFviewBookmarkSaved +---@field __o? table +---@field items PDFviewBookmarkSavedData[] + ---@class PDFviewCfg ---@field path string ---@field save string @@ -69,6 +73,7 @@ ---@class PDFviewStateO ---@field pages table +---@field mtime integer ---@field page_offset integer ---@field total_pages integer ---@field current_page integer diff --git a/lua/pdfview/utils.lua b/lua/pdfview/utils.lua index 605c92d..53f6214 100644 --- a/lua/pdfview/utils.lua +++ b/lua/pdfview/utils.lua @@ -224,7 +224,7 @@ local get_hl = function(group) return vim.api.nvim_get_hl(0, { name = group }) end ----@return PDFviewBookmarkSaved[]|nil +---@return PDFviewBookmarkSaved|nil function M.get_pdf_bookmarks() local Config = require "pdfview.config" local file_saved = Config.defaults.save @@ -299,10 +299,10 @@ local function __add_text_search_indicator(opts, state) ) local segments = { - { text = sep_back, hl = "PDFviewStatusSearchSep" }, - { text = icon, hl = "PDFviewStatusSearchIcon" }, + { text = sep_back, hl = "PDFviewStatusSearchSep" }, + { text = icon, hl = "PDFviewStatusSearchIcon" }, { text = " " .. text, hl = nil }, - { text = sep_front, hl = "PDFviewStatusSearchSep" }, + { text = sep_front, hl = "PDFviewStatusSearchSep" }, } local full_line = "" From 1e1ea9e865f156d1e608ce6bbc0acf8ace7764f8 Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Tue, 4 Aug 2026 06:14:59 +0700 Subject: [PATCH 23/26] fix: track search index in state and sync it across pickers --- lua/pdfview/init.lua | 1 + lua/pdfview/keymaps.lua | 5 +++-- lua/pdfview/pickers/default.lua | 6 +++++- lua/pdfview/pickers/fzf-lua.lua | 8 ++++++-- lua/pdfview/pickers/snacks.lua | 6 +++++- lua/pdfview/pickers/telescope.lua | 6 +++++- lua/pdfview/search.lua | 4 ++++ lua/pdfview/types.lua | 2 ++ 8 files changed, 31 insertions(+), 7 deletions(-) diff --git a/lua/pdfview/init.lua b/lua/pdfview/init.lua index 68cf357..2c6ddc8 100644 --- a/lua/pdfview/init.lua +++ b/lua/pdfview/init.lua @@ -261,6 +261,7 @@ local function search_to(pdf_path, query, state) state.search = state.search or { cache = {} } state.search.cache[query] = matches state.search.current_query = query + state.search.current_idx = 0 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)) diff --git a/lua/pdfview/keymaps.lua b/lua/pdfview/keymaps.lua index dac1151..b1a0e47 100644 --- a/lua/pdfview/keymaps.lua +++ b/lua/pdfview/keymaps.lua @@ -67,8 +67,6 @@ local function save(state, is_last_open) Util.info("bookmark", string.format("Saved: %s · %s", _data.text_page, _data.text_path)) end -local idx = 1 - ---@param state PDFviewStateRender ---@param step integer ---@param bufnr integer @@ -78,11 +76,14 @@ local function search(state, step, bufnr) end local items = state.search.cache[state.search.current_query] + local idx = state.search.current_idx or 0 local total_items = #items idx = ((idx - 1 + step) % total_items) + 1 local item = items[idx] + state.search.current_idx = idx + require("pdfview").go_to(item.page, state) Util.__add_buf_highlight(item, state, idx, total_items) diff --git a/lua/pdfview/pickers/default.lua b/lua/pdfview/pickers/default.lua index 8b10182..f90c940 100644 --- a/lua/pdfview/pickers/default.lua +++ b/lua/pdfview/pickers/default.lua @@ -138,8 +138,12 @@ function M.search() return end + state.search.current_idx = item.search_idx + local items = state.search.cache[state.search.current_query] + local total_items = #items + require("pdfview").go_to(item.page, state, true) - Util.__add_buf_highlight(item, state) + Util.__add_buf_highlight(item, state, state.search.current_idx, total_items) end ) end diff --git a/lua/pdfview/pickers/fzf-lua.lua b/lua/pdfview/pickers/fzf-lua.lua index 82c639d..2b2fe70 100644 --- a/lua/pdfview/pickers/fzf-lua.lua +++ b/lua/pdfview/pickers/fzf-lua.lua @@ -137,8 +137,12 @@ function Mapping.search(state, seen) return end + state.search.current_idx = item.search_idx + local items = state.search.cache[state.search.current_query] + local total_items = #items + require("pdfview").go_to(item.page, state, true) - Util.__add_buf_highlight(item, state) + Util.__add_buf_highlight(item, state, state.search.current_idx, total_items) end end @@ -218,7 +222,7 @@ function M.search() FzfLua.fzf_exec(contents, { no_header = true, no_header_i = true, - fzf_opts = { ["--header"] = [[:delete]] }, + -- fzf_opts = { ["--header"] = [[:delete]] }, winopts = { title = Util.format_title "", preview = { hidden = true } }, actions = { ["default"] = Mapping.search(state, seen), diff --git a/lua/pdfview/pickers/snacks.lua b/lua/pdfview/pickers/snacks.lua index 67ded53..f9cce1e 100644 --- a/lua/pdfview/pickers/snacks.lua +++ b/lua/pdfview/pickers/snacks.lua @@ -94,8 +94,12 @@ function Mapping.search(state, seen, selection) return end + state.search.current_idx = item.search_idx + local items = state.search.cache[state.search.current_query] + local total_items = #items + require("pdfview").go_to(item.page, state, true) - Util.__add_buf_highlight(item, state) + Util.__add_buf_highlight(item, state, state.search.current_idx, total_items) end ---@return boolean diff --git a/lua/pdfview/pickers/telescope.lua b/lua/pdfview/pickers/telescope.lua index 448ad10..d989605 100644 --- a/lua/pdfview/pickers/telescope.lua +++ b/lua/pdfview/pickers/telescope.lua @@ -98,8 +98,12 @@ function Mapping.search(state, seen, entry) return end + state.search.current_idx = item.search_idx + local items = state.search.cache[state.search.current_query] + local total_items = #items + require("pdfview").go_to(item.page, state, true) - Util.__add_buf_highlight(item, state) + Util.__add_buf_highlight(item, state, state.search.current_idx, total_items) end ---@return boolean diff --git a/lua/pdfview/search.lua b/lua/pdfview/search.lua index dcc1b43..e934276 100644 --- a/lua/pdfview/search.lua +++ b/lua/pdfview/search.lua @@ -26,6 +26,7 @@ function M.find_matches(pdf_path, query, state, opts) local matches = {} local pattern = opts.case_sensitive and query or query:lower() + local idx = 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() @@ -33,12 +34,15 @@ function M.find_matches(pdf_path, query, state, opts) if col then local txt_trim = vim.trim(line) table.insert(matches, { + search_idx = idx, page = page_num, line = line_num, col = col, text = vim.trim(line), text_line = string.format("[p.%d] %s", page_num, txt_trim), }) + + idx = idx + 1 end end end diff --git a/lua/pdfview/types.lua b/lua/pdfview/types.lua index 65f606a..2dbb302 100644 --- a/lua/pdfview/types.lua +++ b/lua/pdfview/types.lua @@ -60,6 +60,7 @@ ---@field show_helps? {key: string|table, desc: string, type?: "menu"|"global"}[] ---@class PDFviewMatch +---@field search_idx integer ---@field page integer ---@field line integer ---@field col integer @@ -69,6 +70,7 @@ ---@class PDFviewMatchQuery ---@field current_query string +---@field current_idx integer ---@field cache table ---@class PDFviewStateO From fb30a400b8d4bbf61e02cb8e5078987c9bdb13ec Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Tue, 4 Aug 2026 06:44:37 +0700 Subject: [PATCH 24/26] fix(pdfview): prevent menu shortcuts from colliding with show_helps key --- lua/pdfview/config.lua | 2 +- lua/pdfview/ui.lua | 26 ++++++++++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/lua/pdfview/config.lua b/lua/pdfview/config.lua index b309437..07228a4 100644 --- a/lua/pdfview/config.lua +++ b/lua/pdfview/config.lua @@ -48,7 +48,7 @@ M.defaults = { pick_search = "s", next_search_text = "", prev_search_text = "", - show_helps = "?", + show_helps = "g?", }, } diff --git a/lua/pdfview/ui.lua b/lua/pdfview/ui.lua index 68f4445..b6720ec 100644 --- a/lua/pdfview/ui.lua +++ b/lua/pdfview/ui.lua @@ -295,12 +295,28 @@ function view.menu(cfg) local hval = {} local display_lines = {} + local ignore_shortcut_key = { "q" } + + if type(cfg.keymaps.show_helps) == "table" then + ---@diagnostic disable-next-line: param-type-mismatch + vim.list_extend(ignore_shortcut_key, cfg.keymaps.show_helps) + else + table.insert(ignore_shortcut_key, cfg.keymaps.show_helps) + end + + local ignore_set = {} + for _, ch in ipairs(ignore_shortcut_key) do + for i = 1, #ch do + local shortcut = ch:sub(i, i):lower() + ignore_set[shortcut] = true + end + end + local seen = {} - local resolve_shortcut = function(item) + local function resolve_shortcut(item) for i = 1, #item do - local shortcut = item:sub(i, i) - shortcut = shortcut:lower() - if not seen[shortcut] then + local shortcut = item:sub(i, i):lower() + if not ignore_set[shortcut] and not seen[shortcut] then seen[shortcut] = true return shortcut end @@ -319,6 +335,8 @@ function view.menu(cfg) return a:lower() < b:lower() end) + padding_line = padding_line + 5 + local padding_display_lines = 0 for i, item in ipairs(lines) do local shortcut = resolve_shortcut(item) From e69284025dc67649f550accc2c798827c65e09c0 Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Sat, 8 Aug 2026 22:37:56 +0700 Subject: [PATCH 25/26] feat(jumplist): implement Vim-style / page navigation go_to() now records jumps by default (skip_record now correctly used only for jumplist replay), fixing the previous behavior where go_to_page jumps were never recorded. add M.jumplist(step) for / navigation and M.select_jumplist() to pick a jump target directly via picker, keeping hist.pointer in sync with the selection --- lua/pdfview/config.lua | 3 + lua/pdfview/init.lua | 152 ++++++++++++++++++++++++++++-- lua/pdfview/keymaps.lua | 5 +- lua/pdfview/pickers/default.lua | 28 +++++- lua/pdfview/pickers/fzf-lua.lua | 44 ++++++++- lua/pdfview/pickers/init.lua | 2 +- lua/pdfview/pickers/snacks.lua | 49 ++++++++++ lua/pdfview/pickers/telescope.lua | 58 +++++++++++- lua/pdfview/pickers/utils.lua | 41 ++++++++ lua/pdfview/renderer.lua | 1 + lua/pdfview/types.lua | 21 ++++- lua/pdfview/ui.lua | 1 + lua/pdfview/utils.lua | 6 +- 13 files changed, 388 insertions(+), 23 deletions(-) diff --git a/lua/pdfview/config.lua b/lua/pdfview/config.lua index 07228a4..afa34ef 100644 --- a/lua/pdfview/config.lua +++ b/lua/pdfview/config.lua @@ -5,6 +5,7 @@ M.defaults = { path = vim.fn.stdpath "config", save = vim.fn.stdpath "cache", picker = "default", + max_jumplist_page = 20, open = { cb = nil, }, @@ -48,6 +49,8 @@ M.defaults = { pick_search = "s", next_search_text = "", prev_search_text = "", + next_jumplist = "", + prev_jumplist = "", show_helps = "g?", }, } diff --git a/lua/pdfview/init.lua b/lua/pdfview/init.lua index 2c6ddc8..edfd790 100644 --- a/lua/pdfview/init.lua +++ b/lua/pdfview/init.lua @@ -131,19 +131,158 @@ function M.menu() ui.call("menu", Config.defaults) end +---@param state PDFviewStateRender +local function capture_context(state) + local win = state.win or vim.api.nvim_get_current_win() + local ok, pos = pcall(vim.api.nvim_win_get_cursor, win) + local line, col = 1, 0 + if ok then + line, col = pos[1], pos[2] + end + + local text = "" + if state.buf and vim.api.nvim_buf_is_valid(state.buf) then + local ok2, lines = pcall(vim.api.nvim_buf_get_lines, state.buf, line - 1, line, false) + if ok2 and lines[1] then + text = vim.trim(lines[1]) + end + end + + return { line = line, col = col, text = text, created_at = os.time() } +end + +---@param state PDFviewStateRender +---@param entry {page: integer, line: integer, col: integer} +local function restore_cursor(state, entry) + vim.schedule(function() + local win = state.win or vim.api.nvim_get_current_win() + if not win or not vim.api.nvim_win_is_valid(win) then + return + end + + local buf = vim.api.nvim_win_get_buf(win) + local line_count = vim.api.nvim_buf_line_count(buf) + local target_line = math.min(math.max(entry.line or 1, 1), line_count) + + local line_text = vim.api.nvim_buf_get_lines(buf, target_line - 1, target_line, false)[1] or "" + local target_col = math.min(entry.col or 0, #line_text) + + pcall(vim.api.nvim_win_set_cursor, win, { target_line, target_col }) + end) +end + +---@param state PDFviewStateRender +---@param from_page integer +---@param to_page integer +local function record_jump(state, from_page, to_page) + state.history = state.history or { list = {}, pointer = 0 } + local hist = state.history + + if hist.pointer < #hist.list then + for i = #hist.list, hist.pointer + 1, -1 do + table.remove(hist.list, i) + end + end + + local from_entry = capture_context(state) + from_entry.page = from_page + + if not hist.list[#hist.list] or hist.list[#hist.list].page ~= from_page then + table.insert(hist.list, from_entry) + end + + local to_entry = { page = to_page, line = 1, col = 0, text = "", created_at = os.time() } + if not hist.list[#hist.list] or hist.list[#hist.list].page ~= to_page then + table.insert(hist.list, to_entry) + end + + hist.pointer = #hist.list + + local max_hist = Config.defaults.max_jumplist_page or 10 + while #hist.list > max_hist do + table.remove(hist.list, 1) + hist.pointer = math.max(1, hist.pointer - 1) + end +end + +function M.select_jumplist() + local state = renderer.get() + state.history = state.history or { list = {}, pointer = 0 } + local hist = state.history + + if #hist.list == 0 then + Util.warn "no page history yet" + return + end + + picker.select(Config.defaults.picker or "default", "jumplist", nil, function(opts) + if not opts then + return + end + + hist.pointer = opts.idx + local entry = hist.list[hist.pointer] + + M.go_to(entry.page, state, true) + restore_cursor(state, entry) + end) +end + +---@param step integer +---@param state? PDFviewStateRender +function M.jumplist(step, state) + state = state or renderer.get() + state.history = state.history or { list = {}, pointer = 0 } + local hist = state.history + + if #hist.list == 0 then + Util.warn "no page history yet" + return + end + + local new_pointer = hist.pointer + step + if new_pointer < 1 or new_pointer > #hist.list then + Util.warn(step < 0 and "already at oldest position" or "already at newest position") + return + end + + hist.pointer = new_pointer + local entry = hist.list[hist.pointer] + + M.go_to(entry.page, state, true) + restore_cursor(state, entry) +end + ---@param page_num number|nil ---@param state? PDFviewStateRender +---@param skip_record? boolean ---@param notify? boolean -function M.go_to(page_num, state, notify) +function M.go_to(page_num, state, skip_record, notify) state = state or renderer.get() notify = notify or false + skip_record = skip_record or false - if page_num then - state.current_page = page_num + ---@param num integer + local function jump_to_page(num) + if num < 1 or num > state.total_pages then + Util.warn(string.format("page %d out of range (1-%d)", num, state.total_pages)) + return + end + + if not skip_record then + record_jump(state, state.current_page, num) + end + + state.current_page = num renderer.display_current_page() + if notify then Util.info(string.format("Go to page: %d", state.current_page)) end + end + + if page_num then + jump_to_page(page_num) return end @@ -160,12 +299,7 @@ function M.go_to(page_num, state, notify) return end - state.current_page = num - - if notify then - Util.info(string.format("Go to page: %d", state.current_page)) - end - renderer.display_current_page() + jump_to_page(num) end) end diff --git a/lua/pdfview/keymaps.lua b/lua/pdfview/keymaps.lua index b1a0e47..2b92879 100644 --- a/lua/pdfview/keymaps.lua +++ b/lua/pdfview/keymaps.lua @@ -128,7 +128,7 @@ local function setup_pdfview_ft_mappings(ctx, state) -- 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.go_to_page, fun = function() pdfview.go_to(nil, state, false) 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 }, @@ -141,6 +141,9 @@ local function setup_pdfview_ft_mappings(ctx, state) { 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 }, + { key = keymaps.next_jumplist, fun = function() pdfview.jumplist(1) end, desc = "forward history page", buf = bufnr }, + { key = keymaps.prev_jumplist, fun = function() pdfview.jumplist(-1) end, desc = "backward history page", buf = bufnr }, + { key = keymaps.show_helps, fun = function() require("pdfview.ui").call("show_keymap_helps", Config.defaults) end, desc = "show helps", buf = bufnr }, } diff --git a/lua/pdfview/pickers/default.lua b/lua/pdfview/pickers/default.lua index f90c940..e781d7c 100644 --- a/lua/pdfview/pickers/default.lua +++ b/lua/pdfview/pickers/default.lua @@ -23,9 +23,33 @@ function M.files(path, cb) end) end ----@param path string ---@param cb function -function M.bookmark(path, cb) +function M.jumplist(_, cb) + local renderer = require "pdfview.renderer" + local state = renderer.get() + + local data = UtilPicker.get_jumplist(state) + if not data or Util.is_blank(data.contents) then + return + end + + vim.ui.select(data.contents, { + prompt = Util.format_title "jumplist", + }, function(choice, idx) + if not choice or not idx then + return + end + + for _, _h in pairs(data.hval) do + if _h.text_line == choice then + return cb(_h) + end + end + end) +end + +---@param cb function +function M.bookmark(_, cb) local pdf_bookmark = Util.get_pdf_bookmarks() if not pdf_bookmark or Util.is_blank(pdf_bookmark.items) then Util.warn "No saved pdf bookmarks found." diff --git a/lua/pdfview/pickers/fzf-lua.lua b/lua/pdfview/pickers/fzf-lua.lua index 2b2fe70..2926413 100644 --- a/lua/pdfview/pickers/fzf-lua.lua +++ b/lua/pdfview/pickers/fzf-lua.lua @@ -82,6 +82,24 @@ function Mapping.default_bookmark(pdf_bookmark, cb) end end +---@param data PDFviewJumpL +---@param cb function +function Mapping.default_jumplist(data, cb) + return function(selection) + if not selection then + return + end + + local sel = selection[1] + + for _, _h in pairs(data.hval) do + if _h.text_line == sel then + return cb(_h) + end + end + end +end + ---@param pdf_bookmark PDFviewBookmarkSaved function Mapping.delete_bookmark(pdf_bookmark) return function(selection) @@ -172,9 +190,31 @@ function M.files(path, cb) } end ----@param path string +function M.jumplist(_, cb) + setup_fzflua() + + local renderer = require "pdfview.renderer" + local state = renderer.get() + + local data = UtilPicker.get_jumplist(state) + if not data or Util.is_blank(data.contents) then + return + end + + FzfLua.fzf_exec(data.contents, { + no_header = true, + no_header_i = true, + -- fzf_opts = { ["--header"] = [[ delete]] }, + winopts = { title = Util.format_title "jumplist", preview = { hidden = true } }, + actions = { + ["default"] = Mapping.default_jumplist(data, cb), + -- ["ctrl-x"] = Mapping.delete_jumplist(hist), + }, + }) +end + ---@param cb function -function M.bookmark(path, cb) +function M.bookmark(_, cb) setup_fzflua() if not loaded then diff --git a/lua/pdfview/pickers/init.lua b/lua/pdfview/pickers/init.lua index 7b4746a..5c690c4 100644 --- a/lua/pdfview/pickers/init.lua +++ b/lua/pdfview/pickers/init.lua @@ -69,7 +69,7 @@ end ---@param picker_name string ---@param method string ----@param path string +---@param path string|nil ---@param cb function|nil function M.select(picker_name, method, path, cb) local picker = setup_picker(picker_name) diff --git a/lua/pdfview/pickers/snacks.lua b/lua/pdfview/pickers/snacks.lua index f9cce1e..0f7e94f 100644 --- a/lua/pdfview/pickers/snacks.lua +++ b/lua/pdfview/pickers/snacks.lua @@ -20,8 +20,26 @@ local M = {} local Mapping = {} +---@param data PDFviewJumpL +---@param cb function +---@param selection string +function Mapping.default_jumplist(data, cb, selection) + if not selection then + return + end + + local sel = selection + + for _, _h in pairs(data.hval) do + if _h.text_line == sel then + return cb(_h) + end + end +end + ---@param pdf_bookmark PDFviewBookmarkSaved ---@param cb function +---@param selection string function Mapping.default_bookmark(pdf_bookmark, cb, selection) if not selection then return @@ -125,6 +143,37 @@ function M.files(path, cb) } end +---@param cb function +function M.jumplist(_, cb) + local renderer = require "pdfview.renderer" + local state = renderer.get() + + local data = UtilPicker.get_jumplist(state) + if not data or Util.is_blank(data.contents) then + return + end + + local items = {} + for i, line in ipairs(data.contents) do + table.insert(items, { idx = i, text = line }) + end + + snacks.picker.pick { + title = Util.format_title "jumplist", + items = items, + layout = { preset = "select" }, + format = function(item) + return { { item.text } } + end, + confirm = function(picker, item) + picker:close() + if item then + Mapping.default_jumplist(data, cb, item.text) + end + end, + } +end + ---@param path string ---@param cb function function M.bookmark(path, cb) diff --git a/lua/pdfview/pickers/telescope.lua b/lua/pdfview/pickers/telescope.lua index d989605..ee8ba79 100644 --- a/lua/pdfview/pickers/telescope.lua +++ b/lua/pdfview/pickers/telescope.lua @@ -22,6 +22,23 @@ local pdf_previewer = previewers.new_buffer_previewer { local Mapping = {} +---@param data PDFviewJumpL +---@param cb function +---@param entry string +function Mapping.default_jumplist(data, cb, entry) + if not entry then + return + end + + local sel = entry + + for _, _h in pairs(data.hval) do + if _h.text_line == sel then + return cb(_h) + end + end +end + ---@param pdf_bookmark PDFviewBookmarkSaved ---@param cb function ---@param entry string @@ -132,9 +149,46 @@ function M.files(path, cb) } end ----@param path string ---@param cb function -function M.bookmark(path, cb) +function M.jumplist(_, cb) + local renderer = require "pdfview.renderer" + local state = renderer.get() + + local data = UtilPicker.get_jumplist(state) + if not data or Util.is_blank(data.contents) then + return + end + + pickers + .new({}, { + prompt_title = Util.format_title "jumplist", + finder = finders.new_table { + results = data.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.default_jumplist(data, cb, entry.value) + end + end) + return true + end, + }) + :find() +end + +---@param cb function +function M.bookmark(_, cb) local pdf_bookmark = Util.get_pdf_bookmarks() if not pdf_bookmark or Util.is_blank(pdf_bookmark.items) then Util.warn "No saved pdf bookmarks found." diff --git a/lua/pdfview/pickers/utils.lua b/lua/pdfview/pickers/utils.lua index 980bab0..e1fd97b 100644 --- a/lua/pdfview/pickers/utils.lua +++ b/lua/pdfview/pickers/utils.lua @@ -39,6 +39,47 @@ function M.bookmark_contents(pdf_bookmarks_data) return contents end +---@alias PDFviewJumpL { contents: table, hval: {idx: integer, text_line: string, page: integer, text: string}[] } } + +---@param state PDFviewStateRender +---@return PDFviewJumpL|nil +function M.get_jumplist(state) + if not state.history then + return nil + end + + local hist = state.history + if not hist or #hist.list == 0 then + Util.warn "no page history yet" + return nil + end + + local contents = {} + local hval = {} + + local _pad = 0 + for _, x in ipairs(hist.list) do + local page_str = string.format("[p.%d]", x.page) + if _pad < #page_str then + _pad = #page_str + end + end + + for idx, _h in ipairs(hist.list) do + local page_str = string.format("[p.%d]", _h.page) + local text_line = string.format("%-" .. _pad .. "s %s", page_str, _h.text ~= "" and _h.text or "(no preview)") + contents[#contents + 1] = text_line + hval[#hval + 1] = { + idx = idx, + text_line = text_line, + page = _h.page, + text = _h.text, + } + end + + return { contents = contents, hval = hval } +end + ---@param state PDFviewStateRender ---@return {contents: table, seen: table}|nil function M.search_cache(state) diff --git a/lua/pdfview/renderer.lua b/lua/pdfview/renderer.lua index efc12cf..6be5d9f 100644 --- a/lua/pdfview/renderer.lua +++ b/lua/pdfview/renderer.lua @@ -16,6 +16,7 @@ local state = { ns_search_id = nil, pages = {}, o = {}, + history = { list = {}, pointer = 0 }, } -- Function to display the current page diff --git a/lua/pdfview/types.lua b/lua/pdfview/types.lua index 2dbb302..e4a1b97 100644 --- a/lua/pdfview/types.lua +++ b/lua/pdfview/types.lua @@ -17,6 +17,8 @@ ---@field pick_search string ---@field next_search_text string ---@field prev_search_text string +---@field next_jumplist string +---@field prev_jumplist string ---@field show_helps string ---@class PDFviewPopupTextSearchHl @@ -50,6 +52,7 @@ ---@field path string ---@field save string ---@field picker string +---@field max_jumplist_page integer ---@field window PDFviewWindow ---@field pdf_path string|nil ---@field keymaps PDFviewKeymaps @@ -68,10 +71,21 @@ ---@field text_line string ---@field filename string ----@class PDFviewMatchQuery +---@class PDFviewJumpHist +---@field page integer +---@field line integer +---@field col integer +---@field text string +---@field created_at integer + +---@class PDFviewSearch ---@field current_query string ---@field current_idx integer ----@field cache table +---@field cache table + +---@class PDFviewJumpList +---@field pointer integer +---@field list PDFviewJumpHist[] ---@class PDFviewStateO ---@field pages table @@ -94,4 +108,5 @@ ---@field win_status_search_indicator? integer ---@field orginal_text? string ---@field win? integer ----@field search? PDFviewMatchQuery +---@field search? PDFviewSearch +---@field history PDFviewJumpList diff --git a/lua/pdfview/ui.lua b/lua/pdfview/ui.lua index b6720ec..57c7f2f 100644 --- a/lua/pdfview/ui.lua +++ b/lua/pdfview/ui.lua @@ -285,6 +285,7 @@ function view.menu(cfg) lines[#lines + 1] = "Go to" lines[#lines + 1] = "Text search" lines[#lines + 1] = "Select text search" + lines[#lines + 1] = "Select jumplist" if cfg.picker and cfg.picker == "default" then lines[#lines + 1] = "Delete item bookmark" diff --git a/lua/pdfview/utils.lua b/lua/pdfview/utils.lua index 53f6214..e30745f 100644 --- a/lua/pdfview/utils.lua +++ b/lua/pdfview/utils.lua @@ -299,10 +299,10 @@ local function __add_text_search_indicator(opts, state) ) local segments = { - { text = sep_back, hl = "PDFviewStatusSearchSep" }, - { text = icon, hl = "PDFviewStatusSearchIcon" }, + { text = sep_back, hl = "PDFviewStatusSearchSep" }, + { text = icon, hl = "PDFviewStatusSearchIcon" }, { text = " " .. text, hl = nil }, - { text = sep_front, hl = "PDFviewStatusSearchSep" }, + { text = sep_front, hl = "PDFviewStatusSearchSep" }, } local full_line = "" From 44e347be1f7345eed62d6b671b53a60b5be5c9c5 Mon Sep 17 00:00:00 2001 From: MadKuntilanak Date: Sat, 8 Aug 2026 22:48:28 +0700 Subject: [PATCH 26/26] feat(readme): update README with new configs --- README.md | 71 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 8a29806..a038f5c 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,8 @@ - **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 - - --- ## Installation @@ -28,10 +25,13 @@ - **Neovim** version 0.5 or higher - **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 ``` @@ -51,11 +51,37 @@ To install `PDFview.nvim` using **LazyVim**, add the following configuration to 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) + max_jumplist_page = 20, open = { cb = nil, }, window = { winhighlight = nil, + text_search_indicator = { + search = { + fg = { hl = "Normal", attr = "bg" }, + bg = { hl = "Function", attr = "fg" }, + bold = true, + }, + icon = { + text = " ", + fg = { hl = "Normal", attr = "bg" }, + bg = { hl = "WarningMsg", attr = "fg" }, + }, + + sep = { + back = "", + front = "", + fg = { hl = "Normal", attr = "bg" }, + bg = { hl = "WarningMsg", attr = "fg" }, + }, + + text = { + fg = { hl = "Normal", attr = "bg" }, + bg = { hl = "WarningMsg", attr = "fg" }, + bold = true, + }, + }, }, keymaps = { menu = "", @@ -63,8 +89,15 @@ To install `PDFview.nvim` using **LazyVim**, add the following configuration to 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 = "", + next_jumplist = "", + prev_jumplist = "", + show_helps = "g?", }, keys = { { @@ -107,11 +140,13 @@ map("n", "kk", ":lua require('pdfview.renderer').previous_page()kk", ":lua require('pdfview.renderer').previous_page()kk", ":lua require('pdfview.renderer').previous_page()