diff --git a/lua/winmove/init.lua b/lua/winmove/init.lua index 089fcb3..75dd63c 100644 --- a/lua/winmove/init.lua +++ b/lua/winmove/init.lua @@ -7,11 +7,12 @@ local float = require("winmove.float") local highlight = require("winmove.highlight") local layout = require("winmove.layout") local message = require("winmove.message") +local _mode = require("winmove.mode") local resize = require("winmove.resize") local str = require("winmove.util.str") local winutil = require("winmove.winutil") -winmove.Mode = require("winmove.mode") +winmove.Mode = _mode.Mode winmove.ResizeAnchor = require("winmove.resize").anchor local api = vim.api @@ -34,6 +35,18 @@ local state = { saved_keymaps = nil, } +---@param value any +---@return boolean +local function is_nonnegative_number(value) + return type(value) == "number" and value >= 0 +end + +---@param value any +---@return boolean +local function is_valid_direction(value) + return value == "h" or value == "j" or value == "k" or value == "l" +end + --- Set current state ---@param mode winmove.Mode ---@param win_id integer @@ -71,20 +84,20 @@ end ---@alias winmove.VerticalDirection "k" | "j" ---@alias winmove.Direction winmove.HorizontalDirection | winmove.VerticalDirection ----@param source_win_id integer +---@param win_id integer ---@param func function ---@param ... any -local function wincall(source_win_id, func, ...) +local function wincall(win_id, func, ...) -- NOTE: Using vim.api.nvim_win_call seems to trigger 'textlock' or leaves -- nvim in a weird state where the process exists with either code 134 or -- 139 so we are instead using 'wincall_no_events'. This might also happen -- because we would close the window inside the vim.api.nvim_win_call call -- when moving the window to another tab local cur_win_id = api.nvim_get_current_win() - local is_same_window_id = cur_win_id == source_win_id + local is_same_window_id = cur_win_id == win_id if not is_same_window_id then - winutil.wincall_no_events(api.nvim_set_current_win, source_win_id) + winutil.wincall_no_events(api.nvim_set_current_win, win_id) end winutil.wincall_no_events(func, ...) @@ -94,17 +107,17 @@ local function wincall(source_win_id, func, ...) end end ----@param source_win_id integer +---@param win_id integer ---@param target_win_id integer ---@param dir winmove.Direction ---@param vertical boolean -local function move_window_to_tab(source_win_id, target_win_id, dir, vertical) - local source_buffer = api.nvim_win_get_buf(source_win_id) +local function move_window_to_tab(win_id, target_win_id, dir, vertical) + local source_buffer = api.nvim_win_get_buf(win_id) -- https://github.com/neovim/neovim/issues/18283 - highlight.unhighlight_window(source_win_id) + highlight.unhighlight_window(win_id) - if not winutil.wincall_no_events(api.nvim_win_close, source_win_id, false) then + if not winutil.wincall_no_events(api.nvim_win_close, win_id, false) then return end @@ -129,20 +142,20 @@ local function move_window_to_tab(source_win_id, target_win_id, dir, vertical) end end ----@param source_win_id integer +---@param win_id integer ---@param dir winmove.Direction ---@param behaviour winmove.AtEdge ---@param split_into boolean ---@return boolean ---@return integer? ---@return winmove.Direction -local function handle_edge(source_win_id, dir, behaviour, split_into) +local function handle_edge(win_id, dir, behaviour, split_into) if behaviour == false then return false, nil, dir elseif behaviour == at_edge.Wrap then local new_target_win_id = layout.get_wraparound_neighbor(dir) - if new_target_win_id == source_win_id then + if new_target_win_id == win_id then -- If we get the same window it is full width/height because we -- wrap around to the same window return false, nil, dir @@ -159,7 +172,7 @@ local function handle_edge(source_win_id, dir, behaviour, split_into) end ---@cast dir winmove.HorizontalDirection - local target_win_id, reldir = layout.get_target_window_in_tab(source_win_id, dir) + local target_win_id, reldir = layout.get_target_window_in_tab(win_id, dir) local final_dir = reldir ---@type winmove.Direction local vertical = false @@ -168,7 +181,7 @@ local function handle_edge(source_win_id, dir, behaviour, split_into) vertical = true end - move_window_to_tab(source_win_id, target_win_id, final_dir, vertical) + move_window_to_tab(win_id, target_win_id, final_dir, vertical) return false, target_win_id, dir else @@ -203,10 +216,10 @@ local function can_move(win_id, mode) end --- Move a window in a given direction ----@param source_win_id integer +---@param win_id integer ---@param dir winmove.Direction -local function move_window(source_win_id, dir) - if not can_move(source_win_id, winmove.Mode.Move) then +local function move_window(win_id, dir) + if not can_move(win_id, winmove.Mode.Move) then return end @@ -216,8 +229,7 @@ local function move_window(source_win_id, dir) if target_win_id == nil then local edge_type = winutil.is_horizontal(dir) and "horizontal" or "vertical" local behaviour = config.at_edge[edge_type] - local proceed, new_target_win_id, new_dir = - handle_edge(source_win_id, dir, behaviour, false) + local proceed, new_target_win_id, new_dir = handle_edge(win_id, dir, behaviour, false) if not proceed then return @@ -227,31 +239,37 @@ local function move_window(source_win_id, dir) dir = new_dir end - ---@diagnostic disable-next-line:param-type-mismatch - if not layout.are_siblings(source_win_id, target_win_id) then - ---@diagnostic disable-next-line:param-type-mismatch - dir = layout.get_sibling_relative_dir(source_win_id, target_win_id, dir) + ---@cast target_win_id -nil + + if not layout.are_siblings(win_id, target_win_id) then + dir = layout.get_sibling_relative_dir(win_id, target_win_id, dir, winmove.current_mode()) end winutil.wincall_no_events( vim.fn.win_splitmove, - source_win_id, + win_id, target_win_id, { vertical = winutil.is_horizontal(dir), rightbelow = dir == "j" or dir == "l" } ) end --- Move a window in a given direction ----@param source_win_id integer +---@param win_id integer ---@param dir winmove.Direction -function winmove.move_window(source_win_id, dir) - wincall(source_win_id, move_window, source_win_id, dir) +function winmove.move_window(win_id, dir) + vim.validate({ + win_id = { win_id, is_nonnegative_number, "a non-negative number" }, + dir = { dir, is_valid_direction, "a valid direction" }, + }) + + wincall(win_id, move_window, win_id, dir) end + -- --- Split a window into another window in a given direction ----@param source_win_id integer +---@param win_id integer ---@param dir winmove.Direction -local function split_into(source_win_id, dir) +local function split_into(win_id, dir) if winutil.window_count() == 1 then return end @@ -262,7 +280,7 @@ local function split_into(source_win_id, dir) if target_win_id == nil then local edge_type = winutil.is_horizontal(dir) and "horizontal" or "vertical" local behaviour = config.at_edge[edge_type] - local proceed, new_target_win_id, new_dir = handle_edge(source_win_id, dir, behaviour, true) + local proceed, new_target_win_id, new_dir = handle_edge(win_id, dir, behaviour, true) if not proceed then return @@ -278,32 +296,45 @@ local function split_into(source_win_id, dir) } ---@diagnostic disable-next-line:param-type-mismatch - if layout.are_siblings(source_win_id, target_win_id) then + if layout.are_siblings(win_id, target_win_id) then ---@diagnostic disable-next-line:param-type-mismatch - local reldir = layout.get_sibling_relative_dir(source_win_id, target_win_id, dir) + local reldir = layout.get_sibling_relative_dir(win_id, target_win_id, dir) split_options.vertical = not split_options.vertical split_options.rightbelow = reldir == "l" or reldir == "j" end - winutil.wincall_no_events(vim.fn.win_splitmove, source_win_id, target_win_id, split_options) + winutil.wincall_no_events(vim.fn.win_splitmove, win_id, target_win_id, split_options) end --- Split a window into another window in a given direction ----@param source_win_id integer +---@param win_id integer ---@param dir winmove.Direction -function winmove.split_into(source_win_id, dir) - wincall(source_win_id, split_into, source_win_id, dir) +function winmove.split_into(win_id, dir) + vim.validate({ + win_id = { win_id, is_nonnegative_number, "a non-negative number" }, + dir = { dir, is_valid_direction, "a valid direction" }, + }) + + wincall(win_id, split_into, win_id, dir) +end + +---@param dir winmove.Direction +local function move_window_far(dir) + vim.cmd("wincmd " .. dir:upper()) end ---@diagnostic disable-next-line:unused-local --- Move a window as far as possible in a direction ----@param source_win_id integer +---@param win_id integer ---@param dir winmove.Direction -function winmove.move_window_far(source_win_id, dir) - wincall(source_win_id, function() - vim.cmd("wincmd " .. dir:upper()) - end) +function winmove.move_window_far(win_id, dir) + vim.validate({ + win_id = { win_id, is_nonnegative_number, "a non-negative number" }, + dir = { dir, is_valid_direction, "a valid direction" }, + }) + + wincall(win_id, move_window_far, dir) end local function toggle_mode() @@ -321,30 +352,32 @@ local function move_mode_key_handler(keys) ---@type integer local win_id = state.win_id + -- Make sure to call the local functions to avoid ignoring window and buffer + -- events in move mode if keys == keymaps.left then - winmove.move_window(win_id, "h") + move_window(win_id, "h") elseif keys == keymaps.down then - winmove.move_window(win_id, "j") + move_window(win_id, "j") elseif keys == keymaps.up then - winmove.move_window(win_id, "k") + move_window(win_id, "k") elseif keys == keymaps.right then - winmove.move_window(win_id, "l") + move_window(win_id, "l") elseif keys == keymaps.split_left then - winmove.split_into(win_id, "h") + split_into(win_id, "h") elseif keys == keymaps.split_down then - winmove.split_into(win_id, "j") + split_into(win_id, "j") elseif keys == keymaps.split_up then - winmove.split_into(win_id, "k") + split_into(win_id, "k") elseif keys == keymaps.split_right then - winmove.split_into(win_id, "l") + split_into(win_id, "l") elseif keys == keymaps.far_left then - winmove.move_window_far(win_id, "h") + move_window_far("h") elseif keys == keymaps.far_down then - winmove.move_window_far(win_id, "j") + move_window_far("j") elseif keys == keymaps.far_up then - winmove.move_window_far(win_id, "k") + move_window_far("k") elseif keys == keymaps.far_right then - winmove.move_window_far(win_id, "l") + move_window_far("l") elseif keys == keymaps.resize_mode then toggle_mode() end @@ -520,7 +553,9 @@ local function restore_keymaps(mode) end end -local function create_mode_autocmds(mode, source_win_id) +---@param mode winmove.Mode +---@param win_id integer +local function create_mode_autocmds(mode, win_id) -- Clear any existing autocommands for _, autocmd in ipairs(autocmds) do pcall(api.nvim_del_autocmd, autocmd) @@ -532,11 +567,11 @@ local function create_mode_autocmds(mode, source_win_id) autocmds, api.nvim_create_autocmd("WinEnter", { callback = function() - local win_id = api.nvim_get_current_win() + local cur_win_id = api.nvim_get_current_win() -- Do not stop the current mode if we are entering the window we are -- moving/resizing or if we are entering the help window - if win_id ~= source_win_id and not float.is_help_window(win_id) then + if cur_win_id ~= win_id and not float.is_help_window(cur_win_id) then stop_mode(mode) return true end @@ -553,12 +588,12 @@ local function create_mode_autocmds(mode, source_win_id) autocmds, api.nvim_create_autocmd("WinNew", { callback = function() - local win_id = api.nvim_get_current_win() + local cur_win_id = api.nvim_get_current_win() -- Clear the winhighlight option if the winmove highlighting -- has leaked into the new window - if highlight.has_winmove_highlight(win_id, mode) then - highlight.unhighlight_window(win_id) + if highlight.has_winmove_highlight(cur_win_id, mode) then + highlight.unhighlight_window(cur_win_id) end return true @@ -589,10 +624,8 @@ start_mode = function(mode) return end - local titlecase_mode = str.titlecase(mode) - if winmove.current_mode() == mode then - message.error(titlecase_mode .. " mode already activated") + message.error(str.titlecase(mode) .. " mode already activated") return end @@ -643,17 +676,11 @@ end local sorted_modes = vim.tbl_values(winmove.Mode) table.sort(sorted_modes) -local check_mode_error_message = ("valid mode (%s)"):format(table.concat(sorted_modes, ", ")) - ----@param mode any ----@return boolean -local function is_valid_mode(mode) - return mode == winmove.Mode.Move or mode == winmove.Mode.Resize -end +local check_mode_error_message = ("a valid mode (%s)"):format(table.concat(sorted_modes, ", ")) ---@param mode winmove.Mode function winmove.start_mode(mode) - vim.validate({ mode = { mode, is_valid_mode, check_mode_error_message } }) + vim.validate({ mode = { mode, _mode.is_valid_mode, check_mode_error_message } }) start_mode(mode) end @@ -667,6 +694,13 @@ end ---@param count integer ---@param anchor winmove.ResizeAnchor? function winmove.resize_window(win_id, dir, count, anchor) + vim.validate({ + win_id = { win_id, is_nonnegative_number, "a non-negative number" }, + dir = { dir, is_valid_direction, "a valid direction" }, + count = { count, is_nonnegative_number, "a non-negative number" }, + anchor = { anchor, resize.is_valid_anchor, "a valid anchor" }, + }) + winutil.win_id_context_call(win_id, function() resize.resize_window(win_id, dir, count, anchor) end) diff --git a/lua/winmove/layout.lua b/lua/winmove/layout.lua index ec653b3..8fb6092 100644 --- a/lua/winmove/layout.lua +++ b/lua/winmove/layout.lua @@ -1,6 +1,7 @@ local layout = {} local winutil = require("winmove.winutil") +local Mode = require("winmove.mode") ---@class winmove.BoundingBox ---@field top integer @@ -149,10 +150,24 @@ end ---@param source_win_id integer ---@param target_win_id integer ---@param dir winmove.Direction +---@param mode winmove.Mode? ---@return winmove.Direction -function layout.get_sibling_relative_dir(source_win_id, target_win_id, dir) - local grow, gcol = get_cursor_screen_position(source_win_id) +function layout.get_sibling_relative_dir(source_win_id, target_win_id, dir, mode) + local grow, gcol local bbox = window_bounding_box(target_win_id) + + if mode == Mode.Move then + grow, gcol = get_cursor_screen_position(source_win_id) + else + -- Not in move mode, move relative to the middle of the window in global + -- coordinates + local win_row, win_col = unpack(vim.api.nvim_win_get_position(source_win_id)) + local height = bbox.bottom - bbox.top + local width = bbox.right - bbox.left + + grow, gcol = win_row + height / 2, win_col + width / 2 + end + local vertical = winutil.is_horizontal(dir) local pos = 0 local extents = {} ---@type integer[] diff --git a/lua/winmove/mode.lua b/lua/winmove/mode.lua index 5aa484f..8ec60df 100644 --- a/lua/winmove/mode.lua +++ b/lua/winmove/mode.lua @@ -1,5 +1,15 @@ +local mode = {} + ---@enum winmove.Mode -return { +mode.Mode = { Move = "move", Resize = "resize", } + +---@param value any +---@return boolean +function mode.is_valid_mode(value) + return value == mode.Mode.Move or value == mode.Mode.Resize +end + +return mode diff --git a/lua/winmove/resize.lua b/lua/winmove/resize.lua index 709a841..e02cb33 100644 --- a/lua/winmove/resize.lua +++ b/lua/winmove/resize.lua @@ -115,6 +115,12 @@ local function adjust_neighbors_in_direction(dir, get_dimension, get_min_dimensi end) end +---@param value any +---@return boolean +function resize.is_valid_anchor(value) + return value == nil or (value == resize.anchor.TopLeft or value == resize.anchor.BottomRight) +end + ---@param win_id integer ---@param dir winmove.Direction ---@param count integer diff --git a/tests/handle_error_in_mode_spec.lua b/tests/handle_error_in_mode_spec.lua index ca3b784..03641f6 100644 --- a/tests/handle_error_in_mode_spec.lua +++ b/tests/handle_error_in_mode_spec.lua @@ -36,11 +36,7 @@ describe("error handling in modes", function() script = false, }) - -- Create stubs stub(vim.api, "nvim_echo") - stub(winmove, "move_window", function() - error("Oh noes", 0) - end) winmove.start_mode(winmove.Mode.Move) vim.cmd.normal("l") diff --git a/tests/init_spec.lua b/tests/init_spec.lua index 1415606..d324b20 100644 --- a/tests/init_spec.lua +++ b/tests/init_spec.lua @@ -14,6 +14,13 @@ describe("init", function() assert.is_nil(winmove.current_mode()) end) + it("validates arguments of start_mode", function() + assert.has_error(function() + ---@diagnostic disable-next-line: param-type-mismatch + winmove.start_mode("hello") + end, "mode: expected a valid mode (move, resize), got hello") + end) + it("fails to stop mode if no mode is currently active", function() stub(message, "error") @@ -22,4 +29,62 @@ describe("init", function() assert.stub(message.error).was.called_with("No mode is currently active") message.error:revert() end) + + it("validates arguments of move_window", function() + assert.has_error(function() + ---@diagnostic disable-next-line: param-type-mismatch + winmove.move_window(true, "j") + end, "win_id: expected a non-negative number, got true") + + assert.has_error(function() + ---@diagnostic disable-next-line: param-type-mismatch + winmove.move_window(1000, 1) + end, "dir: expected a valid direction, got 1") + end) + + it("validates arguments of split_into", function() + assert.has_error(function() + ---@diagnostic disable-next-line: param-type-mismatch + winmove.split_into(true, "j") + end, "win_id: expected a non-negative number, got true") + + assert.has_error(function() + ---@diagnostic disable-next-line: param-type-mismatch + winmove.split_into(1000, 1) + end, "dir: expected a valid direction, got 1") + end) + + it("validates arguments of move_window_far", function() + assert.has_error(function() + ---@diagnostic disable-next-line: param-type-mismatch + winmove.move_window_far(true, "j") + end, "win_id: expected a non-negative number, got true") + + assert.has_error(function() + ---@diagnostic disable-next-line: param-type-mismatch + winmove.move_window_far(1000, 1) + end, "dir: expected a valid direction, got 1") + end) + + it("validates arguments of resize_window", function() + assert.has_error(function() + ---@diagnostic disable-next-line: param-type-mismatch + winmove.resize_window(true, "j", 1, winmove.ResizeAnchor.TopLeft) + end, "win_id: expected a non-negative number, got true") + + assert.has_error(function() + ---@diagnostic disable-next-line: param-type-mismatch + winmove.resize_window(1000, "x", 1, winmove.ResizeAnchor.TopLeft) + end, "dir: expected a valid direction, got x") + + assert.has_error(function() + ---@diagnostic disable-next-line: param-type-mismatch + winmove.resize_window(1000, "h", -1, winmove.ResizeAnchor.BottomRight) + end, "count: expected a non-negative number, got -1") + + assert.has_error(function() + ---@diagnostic disable-next-line: param-type-mismatch + winmove.resize_window(1000, "h", 1, "top_right") + end, "anchor: expected a valid anchor, got top_right") + end) end) diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua index 11733e8..7997fdf 100644 --- a/tests/minimal_init.lua +++ b/tests/minimal_init.lua @@ -2,3 +2,8 @@ vim.opt.rtp:append(".") vim.opt.rtp:append("~/.vim-plug/plenary.nvim") vim.cmd.runtime({ "plugin/plenary.vim", bang = true }) vim.cmd.runtime({ "plugin/winmove.lua", bang = false }) + +-- vim.opt.rtp:append("~/.vim-plug/plenary.nvim") +-- vim.opt.rtp:append("~/.vim-plug/nvim-nio") +-- vim.opt.rtp:append("~/.vim-plug/neotest") +-- vim.opt.rtp:append("../neotest-busted")