diff --git a/README.md b/README.md index 37f808a..a80f385 100644 --- a/README.md +++ b/README.md @@ -7,46 +7,107 @@ Menu ui for neovim ( supports nested menus ) https://github.com/user-attachments/assets/89d96170-e039-4d3d-9640-0fdc3358a833 -## Install +## Features + +- LSP Actions menu +- mapleader which-key menu +- neo-tree support +- nvimtree support + +## Installation + +Install the plugin with your package manager: + +[lazy.nvim](https://github.com/folke/lazy.nvim) ```lua -{ "nvzone/volt" , lazy = true }, -{ "nvzone/menu" , lazy = true }, +{ + "nvzone/menu", + dependencies = { "nvzone/volt" }, + opts = { + -- your configuration comes here + -- or leave it empty to use the default settings + }, +} +``` + +Default settings: + +```lua + opts = { + -- Overwrite filetype menu in handler(). If not provided, require("menus.ft." .. filetype) is tried to load the menu. + ft = {}, + -- The default menu to open in handler(), if filetype specific menu is not found. + default_menu = "default", + -- Should we install default mappings? Default mappings presented below. + default_mappings = false, + -- Should the menu have a border? + border = false, + item_gap = 5, + }, ``` ## Usage + +To open a menu, you can use: + ```lua -require("menu").open(options, opts) +require("menu").open(items, opts) ``` -- options is a table or string, if string then it will look at the table from menus* module of this repo -- opts : { mouse = true, border = false }" + +Items is detected to be: +- a function, in which case it is called and it can return a string or a table. +- a string, in which case `require("menus." .. items)` is called. This may result in a function, which is then called. +- the end result has to be a table of menu items. + +Opts has the following attributes: +- **mouse**: (`boolean`) When true, will create menu at cursor position. + +Menu item has the following attributes: +- **name**: (`string`) The name of the item. +- **cmd**: (`string|fun():any`) The command to execute when item is selected. +- **items**: (`MenuItem[]|fun():MenuItem[]`) Submenu items or a function that returns the submenu items (required for submenu) +- **rtxt**: (`string`) Text to show on the right of the item (optional) +- **hl**: (`string`) The hightlight of the item. + +The library provides a default handler: + +```lua +require("menu").handler(opts) +``` + +The handler automatically chooses which menu to open depending on the current filetype. + +Opts has the following attributes: +- **mouse**: (`boolean`) + - When menu is open and **mouse** is set to true, then menu will be closed and a new menu will be repened at new cursor location. + - When menu is open and **mouse** is set to false, menu will just be closed. ### For keyboard users + - Use `h` `l` to move between windows - Use `q` to close the window - Press the keybind defined for menu item or scroll to it and press enter, to execute it -### Examples +### Default mappings + +Keyboard users can run the mapping when inside the menu, mouse users can click. -- Keyboard users can run the mapping when inside the menu, mouse users can click. ```lua -- Keyboard users -vim.keymap.set("n", "", function() - require("menu").open("default") -end, {}) - --- mouse users + nvimtree users! -vim.keymap.set({ "n", "v" }, "", function() - require('menu.utils').delete_old_menus() - - vim.cmd.exec '"normal! \\"' +vim.keymap.set("n", "", function() require("menu").handler {mouse = false} end) +-- Mouse users +vim.keymap.set({ "n", "v" }, "", function() require("menu").handler {mouse = true} end) +``` - -- clicked buf - local buf = vim.api.nvim_win_get_buf(vim.fn.getmousepos().winid) - local options = vim.bo[buf].ft == "NvimTree" and "nvimtree" or "default" +Same settings in lazy.nvim specification: - require("menu").open(options, { mouse = true }) -end, {}) +```lua + keys = { + { mode = "n", "", function() require("menu").handler({ mouse = false }) end }, + { mode = "n", "", function() require("menu").handler({ mouse = true }) end }, + { mode = "v", "", function() require("menu").handler({ mouse = true }) end }, + }, ``` Check example of [defaults menu](https://github.com/NvChad/menu/blob/main/lua/menus/default.lua) to see know syntax of options table. diff --git a/lua/menu/init.lua b/lua/menu/init.lua index d141cdc..de2a8e6 100644 --- a/lua/menu/init.lua +++ b/lua/menu/init.lua @@ -1,26 +1,63 @@ local M = {} -local api = vim.api local state = require "menu.state" local layout = require "menu.layout" -local ns = api.nvim_create_namespace "NvMenu" +local ns = vim.api.nvim_create_namespace "NvMenu" local volt = require "volt" local volt_events = require "volt.events" local mappings = require "menu.mappings" +local utils = require "menu.utils" +---@class MenuItem +---@field name string +---@field cmd? string|fun():any +---@field items? MenuItem[]|fun():MenuItem[] +---@field rtxt? string +---@field hl? string + +---@class MenuOpenOpts +---@field mouse? boolean +---@field nested? boolean + +---@param items string|MenuItem[]|fun():MenuItem[] +---@param opts MenuOpenOpts M.open = function(items, opts) opts = opts or {} - local cur_buf = api.nvim_get_current_buf() + local cur_buf = vim.api.nvim_get_current_buf() if vim.bo[cur_buf].ft ~= "NvMenu" then state.old_data = { - buf = api.nvim_get_current_buf(), - win = api.nvim_get_current_win(), - cursor = api.nvim_win_get_cursor(0), + buf = vim.api.nvim_get_current_buf(), + win = vim.api.nvim_get_current_win(), + cursor = vim.api.nvim_win_get_cursor(0), } end - items = type(items) == "table" and items or require("menus." .. items) + local items_was = items + if type(items) == "function" then + items = items() + end + if type(items) == "string" then + items = require("menus." .. items) + if type(items) == "function" then + items = items() + end + end + assert( + type(items) == "table", + "Items has to be a table." + .. " type(items_was)=" + .. type(items_was) + .. " debug.getinfo(items_was)=" + .. vim.inspect(type(items_was) == "function" and debug.getinfo(items_was)) + .. " type(items)=" + .. type(items) + .. " vim.inspect(items)=" + .. vim.inspect(items) + .. " vim.inspect(opts)=" + .. vim.inspect(opts) + .. ". Most probably provided menus configuration is invalid." + ) if not state.config then state.config = opts @@ -28,13 +65,13 @@ M.open = function(items, opts) local config = state.config - local buf = api.nvim_create_buf(false, true) - state.bufs[buf] = { items = items, item_gap = opts.item_gap or 5 } + local buf = vim.api.nvim_create_buf(false, true) + state.bufs[buf] = { items = items, item_gap = M.config.item_gap or 5 } table.insert(state.bufids, buf) - local h = #items + local h = #items or 1 local bufv = state.bufs[buf] - bufv.w = require("menu.utils").get_width(items) + bufv.w = utils.get_width(items) bufv.w = bufv.w + bufv.item_gap local win_opts = { @@ -54,22 +91,22 @@ M.open = function(items, opts) if config.mouse then local pos = vim.fn.getmousepos() win_opts.win = pos.winid - win_opts.col = api.nvim_win_get_width(pos.winid) + 2 + win_opts.col = vim.api.nvim_win_get_width(pos.winid) + M.config.nested_col win_opts.row = pos.winrow - 2 else - win_opts.win = api.nvim_get_current_win() - win_opts.col = api.nvim_win_get_width(win_opts.win) + 2 - win_opts.row = api.nvim_win_get_cursor(win_opts.win)[1] - 1 + win_opts.win = vim.api.nvim_get_current_win() + win_opts.col = vim.api.nvim_win_get_width(win_opts.win) + M.config.nested_col + win_opts.row = vim.api.nvim_win_get_cursor(win_opts.win)[1] - 1 end end - local win = api.nvim_open_win(buf, not config.mouse, win_opts) + local win = vim.api.nvim_open_win(buf, not config.mouse, win_opts) volt.gen_data { { buf = buf, ns = ns, layout = layout }, } - if config.border then + if M.config.border then vim.wo[win].winhl = "Normal:Normal,FloatBorder:LineNr" else vim.wo[win].winhl = "Normal:ExBlack2Bg,FloatBorder:ExBlack2Border" @@ -84,13 +121,13 @@ M.open = function(items, opts) state.bufs = {} state.config = nil - if api.nvim_win_is_valid(state.old_data.win) then - api.nvim_set_current_win(state.old_data.win) + if vim.api.nvim_win_is_valid(state.old_data.win) then + vim.api.nvim_set_current_win(state.old_data.win) vim.schedule(function() - local cursor_line = math.max(1,state.old_data.cursor[1]) + local cursor_line = math.max(1, state.old_data.cursor[1]) local cursor_col = math.max(0, state.old_data.cursor[2]) - api.nvim_win_set_cursor(state.old_data.win, { cursor_line, cursor_col }) + vim.api.nvim_win_set_cursor(state.old_data.win, { cursor_line, cursor_col }) end) end @@ -107,4 +144,69 @@ M.open = function(items, opts) end end +M.delete_old_menus = utils.delete_old_menus + +---@class MenuConfig +---@field ft? {string: string|MenuItem|fun():MenuItem} +---@field default_menu? string|MenuItem +---@field default_mappings? boolean +---@field border? boolean +---@field item_gap? integer +---@field nested_col? integer + +---@class MenuConfig +M.config = { + ft = {}, + default_menu = "default", + default_mappings = false, + border = false, + item_gap = 5, + nested_col = 2, +} + +---@param args MenuConfig +M.setup = function(args) + M.config = vim.tbl_deep_extend("force", M.config, args or {}) + if M.config.default_mappings then + vim.keymap.set("n", "", function() + M.handler { mouse = false } + end) + vim.keymap.set({ "n", "v" }, "", function() + M.handler { mouse = true } + end) + end +end + +---@param opts MenuOpenOpts +M.handler = function(opts) + opts = opts or {} + local window = 0 + if opts.mouse then + -- On second mouse click remove current manu and reopen it. + require("menu.utils").delete_old_menus() + vim.cmd.exec '"normal! \\"' + window = vim.fn.getmousepos().winid + else + if #require("menu.state").bufids > 0 then + -- if a menu is already open, close it. + require("menu.utils").delete_old_menus() + return + end + end + local ft = vim.bo[vim.api.nvim_win_get_buf(window)].ft + -- First try user filetype overwrites. + local items = M.config.ft[ft] + if not items then + -- Then try filetype specific menus. + local ok, mod = pcall(require, "menus.ft." .. ft) + if ok then + items = mod + else + -- Fallback to defaults. + items = M.config.default_menu or "default" + end + end + M.open(items, opts) +end + return M diff --git a/lua/menus/default.lua b/lua/menus/default.lua index c5cc1ca..c4a85c0 100644 --- a/lua/menus/default.lua +++ b/lua/menus/default.lua @@ -1,4 +1,16 @@ -return { +local function insertafter(arr, after, elem) + local idx = 1 + for i, v in ipairs(arr) do + if v.name:find(after) then + idx = i + break + end + end + table.insert(arr, idx, elem) +end + +---@type MenuItem[] +local items = { { name = "Format Buffer", @@ -25,7 +37,9 @@ return { { name = " Lsp Actions", hl = "Exblue", - items = "lsp", + items = function() + return require "menus.lsp" + end, }, { name = "separator" }, @@ -83,3 +97,24 @@ return { end, }, } + +local ok = require "which-key" +if ok then + insertafter(items, "Lsp Actions", { + name = " Which-key from leader", + hl = "Exblue", + items = function() + local subitems = require "menus.which-key"(vim.g.mapleader)[1].items + return type(subitems) == "function" and subitems() or subitems + end, + }) + insertafter(items, "Lsp Actions", { + name = " Which-key all keys", + hl = "Exblue", + items = function() + return require "menus.which-key"() + end, + }) +end + +return items diff --git a/lua/menus/nvimtree.lua b/lua/menus/ft/NvimTree.lua similarity index 100% rename from lua/menus/nvimtree.lua rename to lua/menus/ft/NvimTree.lua diff --git a/lua/menus/neo-tree.lua b/lua/menus/ft/neo-tree.lua similarity index 96% rename from lua/menus/neo-tree.lua rename to lua/menus/ft/neo-tree.lua index e13dd64..cf02d05 100644 --- a/lua/menus/neo-tree.lua +++ b/lua/menus/ft/neo-tree.lua @@ -22,7 +22,9 @@ end local function copy_path(how) return function() local node = get_state().tree:get_node() - if node.type == "message" then return end + if node.type == "message" then + return + end vim.fn.setreg('"', vim.fn.fnamemodify(node.path, how)) vim.fn.setreg("+", vim.fn.fnamemodify(node.path, how)) end @@ -32,7 +34,9 @@ end local function open_in_terminal() return function() local node = get_state().tree:get_node() - if node.type == "message" then return end + if node.type == "message" then + return + end local path = node.path local node_type = vim.uv.fs_stat(path).type local dir = node_type == "directory" and path or vim.fn.fnamemodify(path, ":h") diff --git a/lua/menus/which-key.lua b/lua/menus/which-key.lua new file mode 100644 index 0000000..e0f6d45 --- /dev/null +++ b/lua/menus/which-key.lua @@ -0,0 +1,69 @@ +---@param self string +---@param start string +local function string_startswith(self, start) + ---@diagnostic disable-next-line: param-type-mismatch + return self:sub(1, #start) == start +end + +local walk + +---@param node wk.Node +---@param prefix string? +---@return MenuItem[]? +local node_to_item = function(node, prefix) + -- Create the name. + local name = node.mapping and node.mapping.desc or node.keymap and (node.keymap.desc or node.keymap.rhs) or node.keys + -- Create element to add. Elements with children have items. + if next(node._children) then + -- If item has children, descend to them. + return { + name = name .. " " .. node.path[#node.path], + items = function() + return walk(node, prefix) or {} + end, + hl = "Exblue", + rtxt = node.path[#node.path], + } + else + -- If item has no children, execute a normal command as part of it. + local feed = vim.api.nvim_replace_termcodes(node.keys, true, true, true) + local cmd = node.action + or node.keymap and node.keymap.callback + or function() + vim.api.nvim_feedkeys(feed, "mit", false) + end + return { name = name, cmd = cmd, rtxt = node.path[#node.path] } + end +end + +---@param node wk.Node +---@param prefix string? +---@return MenuItem[]? +walk = function(node, prefix) + ---@type MenuItem[] + local items = nil + local children = node._children + if children then + items = {} + for _, child in pairs(children) do + if child and child.keys and (not prefix or prefix == "" or string_startswith(child.keys, prefix)) then + table.insert(items, node_to_item(child, prefix)) + end + end + table.sort(items, function(a, b) + return a.name < b.name + end) + end + return items +end + +---@param prefix string? +---@param mode string? +return function(prefix, mode) + local root = require("which-key.buf").get({ mode = mode or "n" }).tree.root + -- print(vim.inspect(root)) + prefix = prefix and vim.g.mapleader:gsub(prefix, "") + local items = walk(root, prefix) + -- print(vim.inspect(menu)) + return items and next(items) and items or nil +end diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..364ef9c --- /dev/null +++ b/stylua.toml @@ -0,0 +1,3 @@ +indent_type = "Spaces" +indent_width = 2 +no_call_parentheses = true