diff --git a/README.md b/README.md index 8a9b3bd..27c51f0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

winmove.nvim

-

Easily move windows around

+

Easily move and swap windows

@@ -65,27 +65,46 @@ active during modes. ```lua require('winmove').configure({ - highlights = { - move = "Search", -- Highlight group for move mode - }, - wrap_around = true, -- Wrap around edges when moving windows keymaps = { help = "?", -- Open floating window with help for the current mode help_close = "q", -- Close the floating help window quit = "q", -- Quit current mode + toggle_mode = "", -- Toggle between modes when in a mode + }, + modes = { move = { - left = "h", -- Move window left - down = "j", -- Move window down - up = "k", -- Move window up - right = "l", -- Move window right - far_left = "H", -- Move window far left and maximize it - far_down = "J", -- Move window down and maximize it - far_up = "K", -- Move window up and maximize it - far_right = "L", -- Move window right and maximize it - split_left = "sh", -- Create a split with the window on the left - split_down = "sj", -- Create a split with the window below - split_up = "sk", -- Create a split with the window above - split_right = "sl", -- Create a split with the window on the right + highlight = "Visual", -- Highlight group for move mode + at_edge = { + horizontal = at_edge.AtEdge.None, -- Behaviour at horizontal edges + vertical = at_edge.AtEdge.None, -- Behaviour at vertical edges + }, + keymaps = { + left = "h", -- Move window left + down = "j", -- Move window down + up = "k", -- Move window up + right = "l", -- Move window right + far_left = "H", -- Move window far left and maximize it + far_down = "J", -- Move window down and maximize it + far_up = "K", -- Move window up and maximize it + far_right = "L", -- Move window right and maximize it + split_left = "sh", -- Create a split with the window on the left + split_down = "sj", -- Create a split with the window below + split_up = "sk", -- Create a split with the window above + split_right = "sl", -- Create a split with the window on the right + }, + }, + swap = { + highlight = "Substitute", -- Highlight group for swap mode + at_edge = { + horizontal = at_edge.AtEdge.None, -- Behaviour at horizontal edges + vertical = at_edge.AtEdge.None, -- Behaviour at vertical edges + }, + keymaps = { + left = "h", -- Swap left + down = "j", -- Swap down + up = "k", -- Swap up + right = "l", -- Swap right + }, }, }, }) @@ -125,7 +144,7 @@ Get the current version of `winmove`. #### `winmove.current_mode` -Check which mode is currently active. Returns `"move"` or `nil`. +Check which mode is currently active. Returns `"move"`, `"swap"`, or `nil`. #### `winmove.start_mode` @@ -137,6 +156,7 @@ winmove.start_mode(mode) -- Example: winmove.start_mode(winmove.Mode.Move) +winmove.start_mode("swap") winmove.start_mode("move") ``` @@ -184,6 +204,36 @@ winmove.move_window_far(win_id, dir) winmove.move_window_far(1000, "h") ``` +#### `winmove.swap_window_in_direction` + +Swap a window in a given direction (does not need to be the current window). + +```lua +---@param win_id integer +---@param dir winmove.Direction +winmove.swap_window_in_direction(win_id, dir) + +-- Example: +winmove.swap_window_in_direction(1000, "j") +winmove.swap_window_in_direction(1000, "l") +``` + +#### `winmove.swap_window` + +Swap a window (does not need to be the current window). When called the first +time, highlights the selected window for swapping. When called the second time +with another window will swap the two selected windows. + +```lua +---@param win_id integer +---@param dir winmove.Direction +winmove.swap_window(win_id, dir) + +-- Example: +winmove.swap_window(1000) +winmove.swap_window(1000) +``` + ## Contributing See [here](/CONTRIBUTING.md). diff --git a/lua/winmove/at_edge.lua b/lua/winmove/at_edge.lua index 558c245..300a274 100644 --- a/lua/winmove/at_edge.lua +++ b/lua/winmove/at_edge.lua @@ -1,5 +1,22 @@ +local at_edge = {} + ---@enum winmove.AtEdge -return { +at_edge.AtEdge = { + None = "none", Wrap = "wrap", MoveToTab = "move_to_tab", } + +---@param value any +---@return boolean +function at_edge.is_valid_behaviour(value) + if type(value) ~= "string" then + return false + end + + return value == at_edge.AtEdge.None + or value == at_edge.AtEdge.Wrap + or value == at_edge.AtEdge.MoveToTab +end + +return at_edge diff --git a/lua/winmove/config.lua b/lua/winmove/config.lua index e77a306..cd212f8 100644 --- a/lua/winmove/config.lua +++ b/lua/winmove/config.lua @@ -5,85 +5,132 @@ local message = require("winmove.message") local config_loaded = false +---@class winmove.ConfigCommonKeymaps +---@field help string +---@field help_close string +---@field quit string +---@field toggle_mode string + ---@class winmove.ConfigMoveModeKeymaps ----@field left string ----@field down string ----@field up string ----@field right string ----@field far_left string ----@field far_down string ----@field far_up string ----@field far_right string +---@field left string +---@field down string +---@field up string +---@field right string +---@field far_left string +---@field far_down string +---@field far_up string +---@field far_right string ---@field split_left string ---@field split_down string ---@field split_up string ---@field split_right string ----@class winmove.ConfigModeKeymaps ----@field help string ----@field help_close string ----@field quit string ----@field move winmove.ConfigMoveModeKeymaps +---@class winmove.ConfigMoveMode +---@field highlight winmove.Highlight +---@field at_edge winmove.AtEdgeConfig +---@field keymaps winmove.ConfigMoveModeKeymaps + +---@class winmove.ConfigSwapModeKeymaps +---@field left string +---@field down string +---@field up string +---@field right string ----@class winmove.Highlights ----@field move string? +---@class winmove.ConfigSwapMode +---@field highlight winmove.Highlight +---@field at_edge winmove.AtEdgeConfig +---@field keymaps winmove.ConfigSwapModeKeymaps + +---@class winmove.ConfigModes +---@field move winmove.ConfigMoveMode +---@field swap winmove.ConfigSwapMode ---@class winmove.AtEdgeConfig ----@field horizontal false | winmove.AtEdge ----@field vertical false | winmove.AtEdge +---@field horizontal winmove.AtEdge +---@field vertical winmove.AtEdge ---@class winmove.Config ----@field highlights winmove.Highlights ----@field at_edge winmove.AtEdgeConfig ----@field keymaps winmove.ConfigModeKeymaps +---@field keymaps winmove.ConfigCommonKeymaps +---@field modes winmove.ConfigModes ---@type winmove.Config local default_config = { - highlights = { - move = "Visual", - }, - at_edge = { - horizontal = at_edge.MoveToTab, - vertical = at_edge.Wrap, - }, keymaps = { help = "?", help_close = "q", quit = "q", + toggle_mode = "", + }, + modes = { move = { - left = "h", - down = "j", - up = "k", - right = "l", - far_left = "H", - far_down = "J", - far_up = "K", - far_right = "L", - split_left = "sh", - split_down = "sj", - split_up = "sk", - split_right = "sl", + highlight = "Visual", + at_edge = { + horizontal = at_edge.AtEdge.None, + vertical = at_edge.AtEdge.None, + }, + keymaps = { + left = "h", + down = "j", + up = "k", + right = "l", + far_left = "H", + far_down = "J", + far_up = "K", + far_right = "L", + split_left = "sh", + split_down = "sj", + split_up = "sk", + split_right = "sl", + }, + }, + swap = { + highlight = "Substitute", + at_edge = { + horizontal = at_edge.AtEdge.None, + vertical = at_edge.AtEdge.None, + }, + keymaps = { + left = "h", + down = "j", + up = "k", + right = "l", + }, }, }, } local mapping_descriptions = { - help = "Show help", - help_close = "Close help", - quit = "Quit current mode", - move = { - left = "Move a window left", - down = "Move a window down", - up = "Move a window up", - right = "Move a window right", - far_left = "Move a window far left and maximize it", - far_down = "Move a window far down and maximize it", - far_up = "Move a window far up and maximize it", - far_right = "Move a window far right and maximize it", - split_left = "Split a window left into another window", - split_down = "Split a window down into another window", - split_up = "Split a window up into another window", - split_right = "Split a window right into another window", + keymaps = { + help = "Show help", + help_close = "Close help", + quit = "Quit current mode", + toggle_mode = "Toggle between modes", + }, + modes = { + move = { + keymaps = { + left = "Move a window left", + down = "Move a window down", + up = "Move a window up", + right = "Move a window right", + far_left = "Move a window far left and maximize it", + far_down = "Move a window far down and maximize it", + far_up = "Move a window far up and maximize it", + far_right = "Move a window far right and maximize it", + split_left = "Split a window left into another window", + split_down = "Split a window down into another window", + split_up = "Split a window up into another window", + split_right = "Split a window right into another window", + }, + }, + swap = { + keymaps = { + left = "Swap window left", + down = "Swap window down", + up = "Swap window up", + right = "Swap window right", + }, + }, }, } @@ -94,9 +141,9 @@ local mapping_descriptions = { function config.get_keymap_description(name, mode) if mode == nil then ---@diagnostic disable-next-line:return-type-mismatch - return mapping_descriptions[name] + return mapping_descriptions.keymaps[name] else - return mapping_descriptions[mode][name] + return mapping_descriptions.modes[mode].keymaps[name] end end @@ -111,93 +158,114 @@ local function is_non_empty_string(value) return type(value) == "string" and #value > 0 end ---- Validate keys in a table ----@param specs table ----@return fun(tbl: table): boolean, any? -local function validate_keys(specs) - return function(tbl) - if not tbl then - return true - end +---@param object table +---@param schema table +---@return table +local function validate_schema(object, schema) + local errors = {} - for _, spec in ipairs(specs) do - local key = spec[1] - local expected = spec[2] + for key, value in pairs(schema) do + if type(value) == "string" then + local ok, err = pcall(vim.validate, { [key] = { object[key], value } }) - local validated, error = pcall(vim.validate, { - [key] = { tbl[key], expected, spec[3] }, - }) + if not ok then + table.insert(errors, err) + end + elseif type(value) == "table" then + if type(object) ~= "table" then + table.insert(errors, "Expected a table at key " .. key) + else + if vim.is_callable(value[1]) then + local ok, err = pcall(vim.validate, { + [key] = { object[key], value[1], value[2] }, + }) - if not validated then - return validated, error + if not ok then + table.insert(errors, err) + end + else + vim.list_extend(errors, validate_schema(object[key], value)) + end end end - - return true end + + return errors end +local expected_non_empty_string = "Expected a non-empty string" + +local horizontal_validator = { + at_edge.is_valid_behaviour, + "valid behaviour at horizontal edge", +} + +local vertical_validator = { + function(value) + return value == at_edge.AtEdge.None or value == at_edge.AtEdge.Wrap + end, + "valid behaviour at vertical edge", +} + +local non_empty_string_validator = { is_non_empty_string, expected_non_empty_string } + --- Validate a config ---@param _config winmove.Config ---@return boolean ---@return any? function config.validate(_config) - local expected_non_empty_string = "Expected a non-empty string" + -- TODO: Validate superfluous keys -- stylua: ignore start - return pcall(vim.validate, { - highlights = { - _config.highlights, - validate_keys({ - { "move", "string" }, - }), + local config_schema = { + keymaps = { + help = "string", + help_close = "string", + quit = "string", + toggle_mode = "string", }, - at_edge = { - _config.at_edge, - validate_keys({ - { - "horizontal", - function(value) - return value == false or value == at_edge.Wrap or value == at_edge.MoveToTab - end, - "valid behaviour at horizontal edge", + modes = { + move = { + highlight = "string", + at_edge = { + horizontal = horizontal_validator, + vertical = vertical_validator, }, - { - "vertical", - function(value) - return value == false or value == at_edge.Wrap - end, - "valid behaviour at vertical edge", + keymaps = { + left = non_empty_string_validator, + down = non_empty_string_validator, + up = non_empty_string_validator, + right = non_empty_string_validator, + far_left = non_empty_string_validator, + far_down = non_empty_string_validator, + far_up = non_empty_string_validator, + far_right = non_empty_string_validator, + split_left = non_empty_string_validator, + split_down = non_empty_string_validator, + split_up = non_empty_string_validator, + split_right = non_empty_string_validator, }, - }), - }, - keymaps = { - _config.keymaps, - validate_keys({ - { "help", "string" }, - { "help_close", "string" }, - { "quit", "string" }, - }), - }, - ["keymaps.move"] = { - _config.keymaps.move, - validate_keys({ - { "left", is_non_empty_string, expected_non_empty_string }, - { "down", is_non_empty_string, expected_non_empty_string }, - { "up", is_non_empty_string, expected_non_empty_string }, - { "right", is_non_empty_string, expected_non_empty_string }, - { "far_left", is_non_empty_string, expected_non_empty_string }, - { "far_down", is_non_empty_string, expected_non_empty_string }, - { "far_up", is_non_empty_string, expected_non_empty_string }, - { "far_right", is_non_empty_string, expected_non_empty_string }, - { "split_left", is_non_empty_string, expected_non_empty_string }, - { "split_down", is_non_empty_string, expected_non_empty_string }, - { "split_up", is_non_empty_string, expected_non_empty_string }, - { "split_right", is_non_empty_string, expected_non_empty_string }, - }), + }, + swap = { + highlight = "string", + at_edge = { + horizontal = horizontal_validator, + vertical = vertical_validator, + }, + keymaps = { + left = non_empty_string_validator, + down = non_empty_string_validator, + up = non_empty_string_validator, + right = non_empty_string_validator, + }, + }, }, - }) + } -- stylua: ignore end + + local errors = validate_schema(_config, config_schema) + + return #errors == 0, errors end ---@type winmove.Config @@ -216,7 +284,7 @@ function config.configure(user_config) local ok, error = config.validate(_user_config) if not ok then - message.error("Errors found in config: " .. error) + message.error("Errors found in config: " .. table.concat(error, "\n")) else config_loaded = true end diff --git a/lua/winmove/float.lua b/lua/winmove/float.lua index 705386a..af88df7 100644 --- a/lua/winmove/float.lua +++ b/lua/winmove/float.lua @@ -108,7 +108,7 @@ end ---@param mode winmove.Mode function float.open(mode) local lines = {} - local keymaps = config.keymaps[mode] + local keymaps = config.modes[mode].keymaps local max_widths = {} local spacing = (" "):rep(4) diff --git a/lua/winmove/highlight.lua b/lua/winmove/highlight.lua index 0837cab..5f387b9 100644 --- a/lua/winmove/highlight.lua +++ b/lua/winmove/highlight.lua @@ -3,6 +3,8 @@ local highlight = {} +---@alias winmove.Highlight string + local config = require("winmove.config") local str = require("winmove.util.str") @@ -11,6 +13,7 @@ local api = vim.api -- Window higlights per mode local win_highlights = { move = nil, + swap = nil, } ---@type string? @@ -40,7 +43,7 @@ local highlight_groups = { ---@param groups string[] local function generate_highlights(mode, groups) local highlights = {} - local color = config.highlights[mode] + local color = config.modes[mode].highlight for _, group in ipairs(groups) do local titlecase_mode = str.titlecase(mode) @@ -85,7 +88,7 @@ end ---@param win_id integer ---@param mode winmove.Mode -function highlight.has_winmove_highlight(win_id, mode) +function highlight.has_highlight(win_id, mode) if not api.nvim_win_is_valid(win_id) then return end diff --git a/lua/winmove/init.lua b/lua/winmove/init.lua index eaf6105..b8fc4ae 100644 --- a/lua/winmove/init.lua +++ b/lua/winmove/init.lua @@ -8,75 +8,21 @@ local highlight = require("winmove.highlight") local layout = require("winmove.layout") local message = require("winmove.message") local _mode = require("winmove.mode") +local State = require("winmove.state") local str = require("winmove.util.str") +local swap = require("winmove.swap") +local validators = require("winmove.validators") local winutil = require("winmove.winutil") winmove.Mode = _mode.Mode +winmove.AtEdge = at_edge.AtEdge local api = vim.api local winmove_version = "0.1.0" local augroup = api.nvim_create_augroup("Winmove", { clear = true }) local autocmds = {} - ----@class winmove.State ----@field mode winmove.Mode? ----@field win_id integer? ----@field bufnr integer? ----@field saved_keymaps table? - ----@type winmove.State -local state = { - mode = nil, - win_id = nil, - bufnr = nil, - 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 -local function win_id_validator(value) - return { value, is_nonnegative_number, "a non-negative number" } -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 - ----@param value any -local function dir_validator(value) - return { value, is_valid_direction, "a valid direction" } -end - ---- Set current state ----@param mode winmove.Mode ----@param win_id integer ----@param bufnr integer ----@param saved_keymaps table -local function set_state(mode, win_id, bufnr, saved_keymaps) - state.mode = mode - state.win_id = win_id - state.bufnr = bufnr - state.saved_keymaps = saved_keymaps -end - -local function update_state(changes) - state = vim.tbl_extend("force", state, changes) -end - -local function reset_state() - state.mode = nil - state.win_id = nil - state.bufnr = nil - state.saved_keymaps = nil -end +local state = State.new() ---@type fun(mode: winmove.Mode) local start_mode @@ -92,29 +38,6 @@ end ---@alias winmove.VerticalDirection "k" | "j" ---@alias winmove.Direction winmove.HorizontalDirection | winmove.VerticalDirection ----@param win_id integer ----@param func function ----@param ... any -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 == win_id - - if not is_same_window_id then - winutil.wincall_no_events(api.nvim_set_current_win, win_id) - end - - winutil.wincall_no_events(func, ...) - - if not is_same_window_id then - winutil.wincall_no_events(api.nvim_set_current_win, cur_win_id) - end -end - ---@param win_id integer ---@param target_win_id integer ---@param dir winmove.Direction @@ -142,11 +65,11 @@ local function move_window_to_tab(win_id, target_win_id, dir, vertical) local new_win_id = api.nvim_get_current_win() local mode = winmove.Mode.Move - highlight.highlight_window(new_win_id, mode) - if winmove.current_mode() == winmove.Mode.Move then + highlight.highlight_window(new_win_id, mode) + -- Update state with the new window - update_state({ win_id = new_win_id }) + state:update({ win_id = new_win_id }) end end @@ -157,15 +80,15 @@ end ---@return boolean ---@return integer? ---@return winmove.Direction -local function handle_edge(win_id, dir, behaviour, split_into) - if behaviour == false then +local function handle_edge(win_id, dir, mode, behaviour, split_into) + if behaviour == winmove.AtEdge.None then return false, nil, dir - elseif behaviour == at_edge.Wrap then - local new_target_win_id = layout.get_wraparound_neighbor(dir) + elseif behaviour == winmove.AtEdge.Wrap then + local new_target_win_id = layout.get_wraparound_neighbor(win_id, dir) 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 + -- wrapped around to the same window return false, nil, dir end @@ -173,12 +96,18 @@ local function handle_edge(win_id, dir, behaviour, split_into) dir = winutil.reverse_direction(dir) return true, target_win_id, dir - elseif behaviour == at_edge.MoveToTab then + elseif behaviour == winmove.AtEdge.MoveToTab then if winutil.window_count() == 1 and vim.fn.tabpagenr("$") == 1 then -- Only one window and one tab, do not proceed return false, nil, dir end + if not winutil.is_horizontal(dir) then + -- Do not try to move to a tab vertically even if the user selected + -- that behaviour by mistake + return false, nil, dir + end + ---@cast dir winmove.HorizontalDirection local target_win_id, reldir = layout.get_target_window_in_tab(win_id, dir) local final_dir = reldir ---@type winmove.Direction @@ -189,7 +118,12 @@ local function handle_edge(win_id, dir, behaviour, split_into) vertical = true end - move_window_to_tab(win_id, target_win_id, final_dir, vertical) + if mode == winmove.Mode.Move then + -- TODO: Refactor so we do not need to call this here + move_window_to_tab(win_id, target_win_id, final_dir, vertical) + elseif mode == winmove.Mode.Swap then + return true, target_win_id, dir + end return false, target_win_id, dir else @@ -200,17 +134,19 @@ end ---@param win_id integer ---@param mode winmove.Mode ---@return boolean -local function can_move(win_id, mode) - local at_edge_horizontal = config.at_edge.horizontal +local function can_move_or_swap(win_id, mode) + local at_edge_horizontal = config.modes.move.at_edge.horizontal - if at_edge_horizontal == at_edge.MoveToTab then + if at_edge_horizontal == winmove.AtEdge.MoveToTab then if winutil.window_count() == 1 and vim.fn.tabpagenr("$") == 1 then - message.error("Only one window and tab") + ---@cast mode string + message.error(("Cannot %s window, only one window and tab"):format(mode:lower())) return false end - elseif at_edge_horizontal == at_edge.Wrap then + elseif at_edge_horizontal == winmove.AtEdge.Wrap then if winutil.window_count() == 1 then - message.error("Only one window") + ---@cast mode string + message.error(("Cannot %s window, only one window"):format(mode:lower())) return false end end @@ -223,30 +159,45 @@ local function can_move(win_id, mode) return true end ---- Move a window in a given direction ---@param win_id integer ---@param dir winmove.Direction -local function move_window(win_id, dir) - if not can_move(win_id, winmove.Mode.Move) then - return - end - - local target_win_id = layout.get_neighbor(dir) +---@param mode winmove.Mode +---@param split_into boolean +---@return boolean +---@return integer? +---@return winmove.Direction +local function find_target_win_id(win_id, dir, mode, split_into) + local target_win_id = layout.get_neighbor(win_id, dir) -- No neighbor, handle configured behaviour at edges 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(win_id, dir, behaviour, false) + local behaviour = config.modes[mode].at_edge[edge_type] + local proceed, new_target_win_id, new_dir = + handle_edge(win_id, dir, mode, behaviour, split_into) - if not proceed then - return - end + return proceed, new_target_win_id, new_dir + end + + return true, target_win_id, dir +end - target_win_id = new_target_win_id - dir = new_dir +--- Move a window in a given direction +---@param win_id integer +---@param dir winmove.Direction +local function move_window(win_id, dir) + if not can_move_or_swap(win_id, winmove.Mode.Move) then + return + end + + local proceed, target_win_id, new_dir = + find_target_win_id(win_id, dir, winmove.Mode.Move, false) + + if not proceed then + return end + dir = new_dir ---@cast target_win_id -nil if not layout.are_siblings(win_id, target_win_id) then @@ -266,14 +217,13 @@ end ---@param dir winmove.Direction function winmove.move_window(win_id, dir) vim.validate({ - win_id = win_id_validator(win_id), - dir = dir_validator(dir), + win_id = validators.win_id_validator(win_id), + dir = validators.dir_validator(dir), }) - wincall(win_id, move_window, win_id, dir) + winutil.wincall(win_id, move_window, win_id, dir) end --- --- Split a window into another window in a given direction ---@param win_id integer ---@param dir winmove.Direction @@ -282,30 +232,21 @@ local function split_into(win_id, dir) return end - local target_win_id = layout.get_neighbor(dir) - - -- No neighbor, handle configured behaviour at edges - 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(win_id, dir, behaviour, true) - - if not proceed then - return - end + local proceed, target_win_id, new_dir = find_target_win_id(win_id, dir, winmove.Mode.Move, true) - target_win_id = new_target_win_id - dir = new_dir + if not proceed then + return end + dir = new_dir + ---@cast target_win_id -nil + local split_options = { vertical = winutil.is_horizontal(dir), rightbelow = dir == "h" or dir == "k", } - ---@diagnostic disable-next-line:param-type-mismatch if layout.are_siblings(win_id, target_win_id) then - ---@diagnostic disable-next-line:param-type-mismatch local reldir = layout.get_sibling_relative_dir(win_id, target_win_id, dir, winmove.current_mode()) @@ -321,16 +262,11 @@ end ---@param dir winmove.Direction function winmove.split_into(win_id, dir) vim.validate({ - win_id = win_id_validator(win_id), - dir = dir_validator(dir), + win_id = validators.win_id_validator(win_id), + dir = validators.dir_validator(dir), }) - wincall(win_id, split_into, win_id, dir) -end - ----@param dir winmove.Direction -local function move_window_far(dir) - vim.cmd("wincmd " .. dir:upper()) + winutil.wincall(win_id, split_into, win_id, dir) end ---@diagnostic disable-next-line:unused-local @@ -339,19 +275,81 @@ end ---@param dir winmove.Direction function winmove.move_window_far(win_id, dir) vim.validate({ - win_id = win_id_validator(win_id), - dir = dir_validator(dir), + win_id = validators.win_id_validator(win_id), + dir = validators.dir_validator(dir), + }) + + winutil.wincall(win_id, function() + vim.cmd("wincmd " .. dir:upper()) + end, dir) +end + +---@param win_id integer +---@param dir winmove.Direction +local function swap_window_in_direction(win_id, dir) + local mode = winmove.Mode.Swap + + if not can_move_or_swap(win_id, mode) then + return + end + + local proceed, target_win_id, _ = find_target_win_id(win_id, dir, mode, false) + + if not proceed then + return + end + + ---@cast target_win_id -nil + swap.swap_window_in_direction(win_id, target_win_id) + + if winmove.current_mode() == winmove.Mode.Swap then + -- Seems the winhighlight bug can also leak into other windows when + -- switching: https://github.com/neovim/neovim/issues/18283 + highlight.unhighlight_window(target_win_id) + highlight.unhighlight_window(win_id) + highlight.highlight_window(target_win_id, mode) + + -- Update state with the new window + state:update({ win_id = target_win_id }) + end +end + +---@param win_id integer +---@param dir winmove.Direction +function winmove.swap_window_in_direction(win_id, dir) + vim.validate({ + win_id = validators.win_id_validator(win_id), + dir = validators.dir_validator(dir), }) - wincall(win_id, move_window_far, dir) + winutil.wincall_no_events(function() + swap_window_in_direction(win_id, dir) + end) +end + +---@param win_id integer +function winmove.swap_window(win_id) + vim.validate({ win_id = validators.win_id_validator(win_id) }) + + winutil.wincall_no_events(function() + swap.swap_window(win_id) + end) +end + +local function toggle_mode() + local mode = winmove.current_mode() + local new_mode = mode == winmove.Mode.Move and winmove.Mode.Swap or winmove.Mode.Move + + stop_mode(mode) + start_mode(new_mode) end ---@param keys string local function move_mode_key_handler(keys) - local keymaps = config.keymaps.move + local keymaps = config.modes.move.keymaps ---@type integer - local win_id = state.win_id + local win_id = state:get("win_id") if keys == keymaps.left then move_window(win_id, "h") @@ -370,13 +368,30 @@ local function move_mode_key_handler(keys) elseif keys == keymaps.split_right then split_into(win_id, "l") elseif keys == keymaps.far_left then - move_window_far("h") + winmove.move_window_far(win_id, "h") elseif keys == keymaps.far_down then - move_window_far("j") + winmove.move_window_far(win_id, "j") elseif keys == keymaps.far_up then - move_window_far("k") + winmove.move_window_far(win_id, "k") elseif keys == keymaps.far_right then - move_window_far("l") + winmove.move_window_far(win_id, "l") + end +end + +---@param keys string +local function swap_mode_key_handler(keys) + ---@type integer + local win_id = state:get("win_id") + local keymaps = config.modes.swap.keymaps + + if keys == keymaps.left then + winmove.swap_window_in_direction(win_id, "h") + elseif keys == keymaps.down then + winmove.swap_window_in_direction(win_id, "j") + elseif keys == keymaps.up then + winmove.swap_window_in_direction(win_id, "k") + elseif keys == keymaps.right then + winmove.swap_window_in_direction(win_id, "l") end end @@ -450,10 +465,15 @@ local function safe_call_autorestore_mode(func, ...) end end +local mode_key_handlers = { + [winmove.Mode.Move] = move_mode_key_handler, + [winmove.Mode.Swap] = swap_mode_key_handler, +} + ---@param mode winmove.Mode ---@return fun(keys: string) local function create_mode_key_handler(mode) - local handler = move_mode_key_handler + local handler = mode_key_handlers[mode] return function(keys) local ok, err = pcall(handler, keys) @@ -474,7 +494,7 @@ local function set_keymaps(win_id, bufnr, mode) local saved_buf_keymaps = {} local handler = create_mode_key_handler(mode) - for name, map in pairs(config.keymaps[mode]) do + for name, map in pairs(config.modes[mode].keymaps) do local description = config.get_keymap_description(name, mode) set_mode_keymap(win_id, bufnr, map, handler, description) @@ -505,29 +525,40 @@ local function set_keymaps(win_id, bufnr, mode) config.get_keymap_description("quit") ) + set_mode_keymap( + win_id, + bufnr, + config.keymaps.toggle_mode, + safe_call_autorestore_mode(toggle_mode), + config.get_keymap_description("toggle_mode") + ) + return saved_buf_keymaps end --- Delete mode keymaps and restore previous buffer keymaps ---@param mode winmove.Mode local function restore_keymaps(mode) - if not api.nvim_buf_is_valid(state.bufnr) then + local bufnr = state:get("bufnr") + + if not api.nvim_buf_is_valid(bufnr) then return end -- Remove winmove keymaps in protected calls since the buffer might have -- been deleted but the buffer can still be marked as valid - for _, map in pairs(config.keymaps[mode]) do - pcall(api.nvim_buf_del_keymap, state.bufnr, "n", map) + for _, map in pairs(config.modes[mode].keymaps) do + pcall(api.nvim_buf_del_keymap, bufnr, "n", map) end - pcall(api.nvim_buf_del_keymap, state.bufnr, "n", config.keymaps.help) - pcall(api.nvim_buf_del_keymap, state.bufnr, "n", config.keymaps.quit) + for _, map in pairs(config.keymaps) do + pcall(api.nvim_buf_del_keymap, bufnr, "n", map) + end -- Restore old keymaps - for _, keymap in ipairs(state.saved_keymaps) do + for _, keymap in ipairs(state:get("saved_keymaps")) do vim.keymap.set("n", keymap.lhs, keymap.rhs, { - buffer = state.bufnr, + buffer = bufnr, expr = keymap.expr, callback = keymap.callback, noremap = keymap.noremap, @@ -548,23 +579,27 @@ local function create_mode_autocmds(mode, win_id) autocmds = {} - table.insert( - autocmds, - api.nvim_create_autocmd("WinEnter", { - callback = function() - local cur_win_id = api.nvim_get_current_win() - - -- Do not stop the current mode if we are entering the window - -- we are moving or if we are entering the help window - if cur_win_id ~= win_id and not float.is_help_window(cur_win_id) then - stop_mode(mode) - return true - end - end, - group = augroup, - desc = "Quits " .. mode .. " when leaving the window", - }) - ) + -- TODO: Do these actually trigger when we ignore them? + + if mode ~= winmove.Mode.Swap then + table.insert( + autocmds, + api.nvim_create_autocmd("WinEnter", { + callback = function() + local cur_win_id = api.nvim_get_current_win() + + -- Do not stop the current mode if we are entering the window + -- we are moving or if we are entering the help window + if cur_win_id ~= win_id and not float.is_help_window(cur_win_id) then + stop_mode(mode) + return true + end + end, + group = augroup, + desc = "Quits " .. mode .. " mode when leaving the window", + }) + ) + end -- If we enter a new window, unhighlight the window since there is a bug -- where the winhighlight option can leak into other windows: @@ -577,7 +612,7 @@ local function create_mode_autocmds(mode, win_id) -- Clear the winhighlight option if the winmove highlighting -- has leaked into the new window - if highlight.has_winmove_highlight(cur_win_id, mode) then + if highlight.has_highlight(cur_win_id, mode) then highlight.unhighlight_window(cur_win_id) end @@ -596,7 +631,7 @@ local function create_mode_autocmds(mode, win_id) return true end, group = augroup, - desc = "Quits " .. mode .. " when entering insert mode", + desc = "Quits " .. mode .. " mode when entering insert mode", }) ) end @@ -605,7 +640,7 @@ end start_mode = function(mode) local cur_win_id = api.nvim_get_current_win() - if not can_move(cur_win_id, mode) then + if not can_move_or_swap(cur_win_id, mode) then return end @@ -618,7 +653,12 @@ start_mode = function(mode) local saved_buf_keymaps = set_keymaps(cur_win_id, bufnr, mode) highlight.highlight_window(cur_win_id, mode) - set_state(mode, cur_win_id, bufnr, saved_buf_keymaps) + state:update({ + mode = mode, + win_id = cur_win_id, + bufnr = bufnr, + saved_keymaps = saved_buf_keymaps, + }) api.nvim_exec_autocmds("User", { pattern = "WinmoveModeStart", @@ -641,7 +681,7 @@ stop_mode = function(mode) return end - local unhighlight_ok = pcall(highlight.unhighlight_window, state.win_id) + local unhighlight_ok = pcall(highlight.unhighlight_window, state:get("win_id")) if not unhighlight_ok then message.error("Failed to unhighlight window when stopping mode") @@ -653,7 +693,7 @@ stop_mode = function(mode) message.error("Failed to restore keymaps when stopping mode") end - reset_state() + state:reset() for _, autocmd in ipairs(autocmds) do pcall(api.nvim_del_autocmd, autocmd) @@ -685,7 +725,7 @@ function winmove.stop_mode() end function winmove.current_mode() - return state.mode + return state:get("mode") end function winmove.configure(user_config) diff --git a/lua/winmove/layout.lua b/lua/winmove/layout.lua index fb5fe50..e257c84 100644 --- a/lua/winmove/layout.lua +++ b/lua/winmove/layout.lua @@ -38,41 +38,55 @@ local function window_bounding_box(win_id) end --- Returns the possible window handle of a neighbor +---@param win_id integer ---@param dir winmove.Direction ---@return integer? -function layout.get_neighbor(dir) - local neighbor = vim.fn.winnr(dir) - local cur_win_nr = vim.fn.winnr() +function layout.get_neighbor(win_id, dir) + local result = nil + + winutil.wincall(win_id, function() + local neighbor = vim.fn.winnr(dir) + local cur_win_nr = vim.fn.winnr() - return cur_win_nr ~= neighbor and vim.fn.win_getid(neighbor) or nil + result = cur_win_nr ~= neighbor and vim.fn.win_getid(neighbor) or nil + end) + + return result end --- Get the neighbor on the opposite side of the screen if the current window --- was to wrap around +---@param win_id integer ---@param dir winmove.Direction ---@return integer? -function layout.get_wraparound_neighbor(dir) +function layout.get_wraparound_neighbor(win_id, dir) if winutil.window_count() == 1 then return nil end - local count = 1 - local opposite_dir = winutil.reverse_direction(dir) - local prev_win_nr = vim.fn.winnr() - local neighbor = nil + local result = nil - while count <= winutil.window_count() do - neighbor = vim.fn.winnr(("%d%s"):format(count, opposite_dir)) + winutil.wincall(win_id, function() + local count = 1 + local opposite_dir = winutil.reverse_direction(dir) + local prev_win_nr = vim.fn.winnr() + local neighbor = nil - if neighbor == prev_win_nr then - break + while count <= winutil.window_count() do + neighbor = vim.fn.winnr(("%d%s"):format(count, opposite_dir)) + + if neighbor == prev_win_nr then + break + end + + count = count + 1 + prev_win_nr = neighbor end - count = count + 1 - prev_win_nr = neighbor - end + result = vim.fn.win_getid(neighbor) + end) - return vim.fn.win_getid(neighbor) + return result end --- Determine if two windows are siblings in the same row or column diff --git a/lua/winmove/mode.lua b/lua/winmove/mode.lua index 94dee07..ee00613 100644 --- a/lua/winmove/mode.lua +++ b/lua/winmove/mode.lua @@ -3,12 +3,17 @@ local mode = {} ---@enum winmove.Mode mode.Mode = { Move = "move", + Swap = "swap", } ---@param value any ---@return boolean function mode.is_valid_mode(value) - return value == mode.Mode.Move + if type(value) ~= "string" then + return false + end + + return value == mode.Mode.Move or value == mode.Mode.Swap end return mode diff --git a/lua/winmove/state.lua b/lua/winmove/state.lua new file mode 100644 index 0000000..b1c5ab9 --- /dev/null +++ b/lua/winmove/state.lua @@ -0,0 +1,51 @@ +---@class winmove.State +---@field mode winmove.Mode? +---@field win_id integer? +---@field bufnr integer? +---@field saved_keymaps table? +local State = {} + +State.__index = State + +---@return winmove.State +function State.new() + return setmetatable({ + mode = nil, + win_id = nil, + bufnr = nil, + saved_keymaps = nil, + }, State) +end + +---@param key string +---@return unknown +function State:get(key) + return self[key] +end + +---@param changes winmove.State +function State:update(changes) + self.mode = changes.mode or self.mode + self.win_id = changes.win_id or self.win_id + self.bufnr = changes.bufnr or self.bufnr + self.saved_keymaps = changes.saved_keymaps or self.saved_keymaps +end + +function State:reset() + self.mode = nil + self.win_id = nil + self.bufnr = nil + self.saved_keymaps = nil +end + +---@return string +function State:__tostring() + return ("State(%s)"):format(vim.inspect({ + mode = self.mode, + win_id = self.win_id, + bufnr = self.bufnr, + saved_keymaps = self.saved_keymaps, + })) +end + +return State diff --git a/lua/winmove/swap.lua b/lua/winmove/swap.lua new file mode 100644 index 0000000..6daebe7 --- /dev/null +++ b/lua/winmove/swap.lua @@ -0,0 +1,67 @@ +local swap = {} + +local highlight = require("winmove.highlight") +local message = require("winmove.message") +local mode = require("winmove.mode") + +---@type integer? +local selected_window + +---@param win_id1 integer +---@param win_id2 integer +local function swap_windows(win_id1, win_id2) + local buf1 = vim.api.nvim_win_get_buf(win_id1) + local buf2 = vim.api.nvim_win_get_buf(win_id2) + + -- Save views before switching buffers + vim.api.nvim_set_current_win(win_id1) + local view1 = vim.fn.winsaveview() + + vim.api.nvim_set_current_win(win_id2) + local view2 = vim.fn.winsaveview() + + -- Set buffers and restore views + vim.api.nvim_win_set_buf(win_id2, buf1) + vim.fn.winrestview(view1) + + vim.api.nvim_set_current_win(win_id1) + vim.api.nvim_win_set_buf(win_id1, buf2) + vim.fn.winrestview(view2) +end + +--- Selects a window for swapping. If no window has been selected already, selects +--- it, otherwise swaps the window with the previously selected window +---@param win_id integer +function swap.swap_window(win_id) + -- Normalize window id + win_id = win_id == 0 and vim.api.nvim_get_current_win() or win_id + + if not selected_window then + selected_window = win_id + highlight.highlight_window(win_id, mode.Mode.Swap) + return + end + + if not vim.api.nvim_win_is_valid(selected_window) then + selected_window = nil + message.error("Previously selected window is not valid anymore") + return + elseif win_id == selected_window then + selected_window = nil + message.error("Cannot swap selected window with itself") + return + end + + highlight.unhighlight_window(selected_window) + swap_windows(win_id, selected_window) + selected_window = nil +end + +---@param win_id integer +---@param target_win_id integer +function swap.swap_window_in_direction(win_id, target_win_id) + swap_windows(win_id, target_win_id) + vim.api.nvim_set_current_win(target_win_id) +end + +return swap diff --git a/lua/winmove/validators.lua b/lua/winmove/validators.lua new file mode 100644 index 0000000..796f920 --- /dev/null +++ b/lua/winmove/validators.lua @@ -0,0 +1,25 @@ +local validators = {} + +---@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 + +---@param value any +function validators.win_id_validator(value) + return { value, is_nonnegative_number, "a non-negative number" } +end + +---@param value any +function validators.dir_validator(value) + return { value, is_valid_direction, "a valid direction" } +end + +return validators diff --git a/lua/winmove/winutil.lua b/lua/winmove/winutil.lua index b06a46b..3926109 100644 --- a/lua/winmove/winutil.lua +++ b/lua/winmove/winutil.lua @@ -3,6 +3,26 @@ local winutil = {} local compat = require("winmove.compat") local message = require("winmove.message") +local events = { + "WinEnter", + "WinLeave", + "WinNew", + "WinScrolled", + "WinClosed", + "BufWinEnter", + "BufWinLeave", + "BufEnter", + "BufLeave", +} + +if compat.has("nvim-0.8.2") then + table.insert(events, "WinResized") +end + +function winutil.get_ignored_events() + return events +end + ---@return integer function winutil.window_count() return vim.fn.winnr("$") @@ -23,23 +43,7 @@ end function winutil.wincall_no_events(func, ...) local saved_eventignore = vim.opt_global.eventignore:get() - local events = { - "WinEnter", - "WinLeave", - "WinNew", - "WinScrolled", - "WinClosed", - "BufWinEnter", - "BufWinLeave", - "BufEnter", - "BufLeave", - } - - if compat.has("nvim-0.8.2") then - table.insert(events, "WinResized") - end - - vim.opt_global.eventignore = events + vim.opt_global.eventignore = winutil.get_ignored_events() -- Do a protected call so that we restore 'eventignore' in case it fails local ok, error = pcall(func, ...) @@ -53,20 +57,27 @@ function winutil.wincall_no_events(func, ...) return ok end ---- Call a function in the context of a window without triggering any window/buffer events ---@param win_id integer ---@param func function ---@param ... any ----@return boolean -function winutil.win_id_context_call(win_id, func, ...) - local args = { ... } - local ok +function winutil.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 = vim.api.nvim_get_current_win() + local is_same_window_id = cur_win_id == win_id + + if not is_same_window_id then + winutil.wincall_no_events(vim.api.nvim_set_current_win, win_id) + end - vim.api.nvim_win_call(win_id, function() - ok = winutil.wincall_no_events(func, unpack(args)) - end) + winutil.wincall_no_events(func, ...) - return ok + if not is_same_window_id then + winutil.wincall_no_events(vim.api.nvim_set_current_win, cur_win_id) + end end ---@param dir winmove.Direction diff --git a/tests/basic_movement_spec.lua b/tests/basic_movement_spec.lua index 04001b2..8bf0103 100644 --- a/tests/basic_movement_spec.lua +++ b/tests/basic_movement_spec.lua @@ -11,9 +11,13 @@ local make_layout = test_helpers.make_layout describe("basic movements", function() config.configure({ - at_edge = { - horizontal = at_edge.Wrap, - vertical = at_edge.Wrap, + modes = { + move = { + at_edge = { + horizontal = at_edge.AtEdge.Wrap, + vertical = at_edge.AtEdge.Wrap, + }, + }, }, }) @@ -138,7 +142,7 @@ describe("basic movements", function() assert .stub(vim.notify).was - .called_with("[winmove.nvim]: Only one window", vim.log.levels.ERROR) + .called_with("[winmove.nvim]: Cannot move window, only one window", vim.log.levels.ERROR) end) ---@diagnostic disable-next-line: undefined-field diff --git a/tests/config_spec.lua b/tests/config_spec.lua index b76650f..2e5b069 100644 --- a/tests/config_spec.lua +++ b/tests/config_spec.lua @@ -7,22 +7,44 @@ describe("config", function() it("handles invalid configs", function() local invalid_configs = { { - highlights = { - move = true, + modes = { + move = { + highlight = true, + }, + }, + }, + { + modes = { + swap = { + at_edge = 2, + }, }, }, { - at_edge = 2, + modes = { + move = { + at_edge = { + vertical = at_edge.AtEdge.MoveToTab, + }, + }, + }, }, { - at_edge = { - horizontal = at_edge.MoveToTab, - vertical = at_edge.MoveToTab, + modes = { + swap = { + at_edge = { + vertical = at_edge.AtEdge.MoveToTab, + }, + }, }, }, { - at_edge = { - vertical = true, + modes = { + move = { + at_edge = { + vertical = true, + }, + }, }, }, { @@ -31,16 +53,20 @@ describe("config", function() }, }, { - keymaps = { - move = { - left = 12.5, + modes = { + swap = { + keymaps = { + left = 12.5, + }, }, }, }, { - keymaps = { - move = { - left = "", + modes = { + swap = { + keymaps = { + left = "", + }, }, }, }, @@ -64,30 +90,46 @@ describe("config", function() it("throws no errors for a valid config", function() local ok = config.configure({ - highlights = { - move = "Title", - }, - at_edge = { - horizontal = at_edge.Wrap, - vertical = false, - }, keymaps = { help = "_", help_close = "z", quit = "i", + toggle_mode = "", + }, + modes = { move = { - left = "", - down = "", - up = "", - right = "", - far_left = "U", - far_down = "I", - far_up = "O", - far_right = "P", - split_left = "ef", - split_down = "nv", - split_up = "qp", - split_right = "vn", + highlight = "Title", + at_edge = { + horizontal = at_edge.AtEdge.Wrap, + vertical = at_edge.AtEdge.None, + }, + keymaps = { + left = "", + down = "", + up = "", + right = "", + far_left = "U", + far_down = "I", + far_up = "O", + far_right = "P", + split_left = "ef", + split_down = "nv", + split_up = "qp", + split_right = "vn", + }, + }, + swap = { + highlight = "Question", + at_edge = { + horizontal = at_edge.AtEdge.MoveToTab, + vertical = at_edge.AtEdge.None, + }, + keymaps = { + left = "", + down = "", + up = "", + right = "", + }, }, }, }) diff --git a/tests/custom_highlights_spec.lua b/tests/custom_highlights_spec.lua index 5dafb2d..4fbde34 100644 --- a/tests/custom_highlights_spec.lua +++ b/tests/custom_highlights_spec.lua @@ -42,9 +42,12 @@ describe("custom highlights", function() it("uses a custom highlight for move mode", function() vim.cmd(("hi link %s %s"):format("CustomWinmoveMoveMode", "Title")) + ---@diagnostic disable-next-line: missing-fields config.configure({ - highlights = { - move = "CustomWinmoveMoveMode", + modes = { + move = { + highlight = "CustomWinmoveMoveMode", + }, }, }) @@ -77,4 +80,46 @@ describe("custom highlights", function() winmove.stop_mode() end) end) + + it("uses a custom highlight for swap mode", function() + vim.cmd(("hi link %s %s"):format("CustomWinmoveSwapMode", "Repeat")) + + ---@diagnostic disable-next-line: missing-fields + config.configure({ + modes = { + swap = { + highlight = "CustomWinmoveSwapMode", + }, + }, + }) + + given(function() + make_layout({ + "row", + { "leaf", "leaf" }, + }) + + assert.matches_winlayout(vim.fn.winlayout(), { + "row", + { + { "leaf" }, + { "leaf" }, + }, + }) + + winmove.start_mode(winmove.Mode.Swap) + + local win_id = vim.api.nvim_get_current_win() + + assert.are.same(vim.wo[win_id].winhighlight, get_expected_winhighlight("WinmoveSwap")) + + for _, group in ipairs(highlight.groups()) do + local linked_group = vim.api.nvim_get_hl(0, { name = "WinmoveSwap" .. group }).link + + assert.are.same(linked_group, "CustomWinmoveSwapMode") + end + + winmove.stop_mode() + end) + end) end) diff --git a/tests/init_spec.lua b/tests/init_spec.lua index e4c95d2..f80967c 100644 --- a/tests/init_spec.lua +++ b/tests/init_spec.lua @@ -18,7 +18,7 @@ describe("init", function() assert.has_error(function() ---@diagnostic disable-next-line: param-type-mismatch winmove.start_mode("hello") - end, "mode: expected a valid mode (move), got hello") + end, "mode: expected a valid mode (move, swap), got hello") end) it("fails to stop mode if no mode is currently active", function() @@ -67,4 +67,23 @@ describe("init", function() winmove.move_window_far(1000, 1) end, "dir: expected a valid direction, got 1") end) + + it("validates arguments of swap_window_in_direction", function() + assert.has_error(function() + ---@diagnostic disable-next-line: param-type-mismatch + winmove.swap_window_in_direction(true, "j") + end, "win_id: expected a non-negative number, got true") + + assert.has_error(function() + ---@diagnostic disable-next-line: param-type-mismatch + winmove.swap_window_in_direction(1000, 1) + end, "dir: expected a valid direction, got 1") + end) + + it("validates arguments of swap_window", function() + assert.has_error(function() + ---@diagnostic disable-next-line: param-type-mismatch + winmove.swap_window(true) + end, "win_id: expected a non-negative number, got true") + end) end) diff --git a/tests/mode_mappings_spec.lua b/tests/mode_mappings_spec.lua index d380c4b..1bcb30b 100644 --- a/tests/mode_mappings_spec.lua +++ b/tests/mode_mappings_spec.lua @@ -27,7 +27,7 @@ describe("mode mappings", function() local keymaps = test_helpers.get_buf_mapped_keymaps(vim.api.nvim_get_current_buf()) - for name, lhs in pairs(config.keymaps.move) do + for name, lhs in pairs(config.modes.move.keymaps) do compare_keymap("move", name, keymaps[lhs] or keymaps[lhs:upper()]) end @@ -35,6 +35,22 @@ describe("mode mappings", function() end) end) + it("sets buffer-only mode mappings when entering swap mode", function() + given(function() + vim.cmd("new") -- Create another buffer to activate swap mode + + winmove.start_mode(winmove.Mode.Swap) + + local keymaps = test_helpers.get_buf_mapped_keymaps(vim.api.nvim_get_current_buf()) + + for name, lhs in pairs(config.modes.swap.keymaps) do + compare_keymap("swap", name, keymaps[lhs] or keymaps[lhs:upper()]) + end + + winmove.stop_mode() + end) + end) + it("restores mappings after exiting a mode", function() given(function() vim.cmd("new") -- Create another buffer to activate move mode diff --git a/tests/move_between_tabs_spec.lua b/tests/move_between_tabs_spec.lua index 3640a9a..fc081d2 100644 --- a/tests/move_between_tabs_spec.lua +++ b/tests/move_between_tabs_spec.lua @@ -10,10 +10,17 @@ local given = vader.given local make_layout = test_helpers.make_layout describe("moving between tabs", function() - -- Ensure default configuration + ---@diagnostic disable-next-line: missing-fields config.configure({ - at_edge = { - horizontal = at_edge.MoveToTab, + ---@diagnostic disable-next-line: missing-fields + modes = { + ---@diagnostic disable-next-line: missing-fields + move = { + ---@diagnostic disable-next-line: missing-fields + at_edge = { + horizontal = at_edge.AtEdge.MoveToTab, + }, + }, }, }) @@ -705,7 +712,9 @@ describe("moving between tabs", function() assert.matches_winlayout(vim.fn.winlayout(), { "leaf", win_id }) - assert.stub(message.error).was.called_with("Only one window and tab") + assert + .stub(message.error).was + .called_with("Cannot move window, only one window and tab") ---@diagnostic disable-next-line: undefined-field message.error:revert() @@ -717,7 +726,9 @@ describe("moving between tabs", function() stub(message, "error") winmove.start_mode(winmove.Mode.Move) - assert.stub(message.error).was.called_with("Only one window and tab") + assert + .stub(message.error).was + .called_with("Cannot move window, only one window and tab") ---@diagnostic disable-next-line: undefined-field message.error:revert() diff --git a/tests/no_win_events_spec.lua b/tests/no_win_events_spec.lua index 27a733a..815c438 100644 --- a/tests/no_win_events_spec.lua +++ b/tests/no_win_events_spec.lua @@ -1,27 +1,24 @@ local winmove = require("winmove") -local compat = require("winmove.compat") +local winutil = require("winmove.winutil") local vader = require("winmove.util.vader") local given = vader.given describe("no window events", function() - local events = { - "WinEnter", - "WinLeave", - "WinNew", - "WinScrolled", - "WinClosed", - "BufWinEnter", - "BufWinLeave", - "BufEnter", - "BufLeave", - } - - if compat.has("nvim-0.8.2") then - table.insert(events, "WinResized") - end - + local events = winutil.get_ignored_events() local triggers = {} + local autocmd_ids = {} + + before_each(function() + triggers = {} + autocmd_ids = {} + end) + + after_each(function() + for _, autocmd_id in ipairs(autocmd_ids) do + pcall(vim.api.nvim_del_autocmd, autocmd_id) + end + end) it("does not trigger any window events when moving", function() given(function() @@ -29,11 +26,13 @@ describe("no window events", function() -- Set up autocmds *after* splitting for _, event in ipairs(events) do - vim.api.nvim_create_autocmd(event, { + local autocmd_id = vim.api.nvim_create_autocmd(event, { callback = function() triggers[event] = true end, }) + + table.insert(autocmd_ids, autocmd_id) end winmove.move_window(vim.api.nvim_get_current_win(), "h") @@ -41,4 +40,25 @@ describe("no window events", function() assert.are.same(triggers, {}) end) end) + + it("does not trigger any window events when swapping", function() + given(function() + vim.cmd.vnew() + + -- Set up autocmds *after* splitting + for _, event in ipairs(events) do + local autocmd_id = vim.api.nvim_create_autocmd(event, { + callback = function() + triggers[event] = true + end, + }) + + table.insert(autocmd_ids, autocmd_id) + end + + winmove.swap_window_in_direction(vim.api.nvim_get_current_win(), "h") + + assert.are.same(triggers, {}) + end) + end) end) diff --git a/tests/split_into_spec.lua b/tests/split_into_spec.lua index 8008ffa..b90f444 100644 --- a/tests/split_into_spec.lua +++ b/tests/split_into_spec.lua @@ -1,4 +1,5 @@ local winmove = require("winmove") +local at_edge = require("winmove.at_edge") local config = require("winmove.config") local vader = require("winmove.util.vader") local test_helpers = require("winmove.util.test_helpers") @@ -9,16 +10,18 @@ local make_layout = test_helpers.make_layout describe("split_into", function() -- Ensure default configuration config.configure({ - at_edge = { - horizontal = false, - vertical = false, - }, - keymaps = { + modes = { move = { - split_left = "sh", - split_down = "sj", - split_up = "sk", - split_right = "sl", + at_edge = { + horizontal = at_edge.AtEdge.None, + vertical = at_edge.AtEdge.None, + }, + keymaps = { + split_left = "sh", + split_down = "sj", + split_up = "sk", + split_right = "sl", + }, }, }, }) diff --git a/tests/state_spec.lua b/tests/state_spec.lua new file mode 100644 index 0000000..a37cb1d --- /dev/null +++ b/tests/state_spec.lua @@ -0,0 +1,31 @@ +local State = require("winmove.state") + +describe("State", function() + it("updates and resets state, gets properties", function() + local state = State.new() + + assert.are.same(state:get("mode"), nil) + assert.are.same(state:get("win_id"), nil) + assert.are.same(state:get("bufnr"), nil) + assert.are.same(state:get("saved_keymaps"), nil) + + state:update({ + mode = "move", + win_id = 1000, + bufnr = 3, + saved_keymaps = {}, + }) + + assert.are.same(state:get("mode"), "move") + assert.are.same(state:get("win_id"), 1000) + assert.are.same(state:get("bufnr"), 3) + assert.are.same(state:get("saved_keymaps"), {}) + + state:reset() + + assert.are.same(state:get("mode"), nil) + assert.are.same(state:get("win_id"), nil) + assert.are.same(state:get("bufnr"), nil) + assert.are.same(state:get("saved_keymaps"), nil) + end) +end) diff --git a/tests/swap_mode_spec.lua b/tests/swap_mode_spec.lua new file mode 100644 index 0000000..81a2410 --- /dev/null +++ b/tests/swap_mode_spec.lua @@ -0,0 +1,152 @@ +local winmove = require("winmove") +local vader = require("winmove.util.vader") +local test_helpers = require("winmove.util.test_helpers") + +local given = vader.given +local make_layout = test_helpers.make_layout + +describe("swap mode", function() + it("swaps window to the left", function() + given(function() + local windows = make_layout({ + "row", + { "target", "main" }, + }) + local main_win_id = windows["main"] + local target_win_id = windows["target"] + + assert.matches_winlayout(vim.fn.winlayout(), { + "row", + { + { "leaf", target_win_id }, + { "leaf", main_win_id }, + }, + }) + + local bufnr1 = vim.api.nvim_win_get_buf(main_win_id) + local bufnr2 = vim.api.nvim_win_get_buf(target_win_id) + + vim.api.nvim_set_current_win(main_win_id) + winmove.swap_window_in_direction(main_win_id, "h") + + assert.matches_winlayout(vim.fn.winlayout(), { + "row", + { + { "leaf", target_win_id }, + { "leaf", main_win_id }, + }, + }) + + assert.are.same(vim.api.nvim_win_get_buf(main_win_id), bufnr2) + assert.are.same(vim.api.nvim_win_get_buf(target_win_id), bufnr1) + end) + end) + + it("swaps window down", function() + given(function() + local windows = make_layout({ + "col", + { "main", "target" }, + }) + local main_win_id = windows["main"] + local target_win_id = windows["target"] + + assert.matches_winlayout(vim.fn.winlayout(), { + "col", + { + { "leaf", main_win_id }, + { "leaf", target_win_id }, + }, + }) + + local bufnr1 = vim.api.nvim_win_get_buf(main_win_id) + local bufnr2 = vim.api.nvim_win_get_buf(target_win_id) + + vim.api.nvim_set_current_win(main_win_id) + winmove.swap_window_in_direction(main_win_id, "j") + + assert.matches_winlayout(vim.fn.winlayout(), { + "col", + { + { "leaf", main_win_id }, + { "leaf", target_win_id }, + }, + }) + + assert.are.same(vim.api.nvim_win_get_buf(main_win_id), bufnr2) + assert.are.same(vim.api.nvim_win_get_buf(target_win_id), bufnr1) + end) + end) + + it("swaps window up", function() + given(function() + local windows = make_layout({ + "col", + { "target", "main" }, + }) + local main_win_id = windows["main"] + local target_win_id = windows["target"] + + assert.matches_winlayout(vim.fn.winlayout(), { + "col", + { + { "leaf", target_win_id }, + { "leaf", main_win_id }, + }, + }) + + local bufnr1 = vim.api.nvim_win_get_buf(main_win_id) + local bufnr2 = vim.api.nvim_win_get_buf(target_win_id) + + vim.api.nvim_set_current_win(main_win_id) + winmove.swap_window_in_direction(main_win_id, "k") + + assert.matches_winlayout(vim.fn.winlayout(), { + "col", + { + { "leaf", target_win_id }, + { "leaf", main_win_id }, + }, + }) + + assert.are.same(vim.api.nvim_win_get_buf(main_win_id), bufnr2) + assert.are.same(vim.api.nvim_win_get_buf(target_win_id), bufnr1) + end) + end) + + it("swaps window to the right", function() + given(function() + local windows = make_layout({ + "row", + { "main", "target" }, + }) + local main_win_id = windows["main"] + local target_win_id = windows["target"] + + assert.matches_winlayout(vim.fn.winlayout(), { + "row", + { + { "leaf", main_win_id }, + { "leaf", target_win_id }, + }, + }) + + local bufnr1 = vim.api.nvim_win_get_buf(main_win_id) + local bufnr2 = vim.api.nvim_win_get_buf(target_win_id) + + vim.api.nvim_set_current_win(main_win_id) + winmove.swap_window_in_direction(main_win_id, "l") + + assert.matches_winlayout(vim.fn.winlayout(), { + "row", + { + { "leaf", main_win_id }, + { "leaf", target_win_id }, + }, + }) + + assert.are.same(vim.api.nvim_win_get_buf(main_win_id), bufnr2) + assert.are.same(vim.api.nvim_win_get_buf(target_win_id), bufnr1) + end) + end) +end) diff --git a/tests/swap_window_spec.lua b/tests/swap_window_spec.lua new file mode 100644 index 0000000..8c14cd0 --- /dev/null +++ b/tests/swap_window_spec.lua @@ -0,0 +1,209 @@ +local winmove = require("winmove") +local vader = require("winmove.util.vader") +local test_helpers = require("winmove.util.test_helpers") +local stub = require("luassert.stub") + +local given = vader.given +local make_layout = test_helpers.make_layout + +describe("swap window", function() + assert:set_parameter("TableFormatLevel", 10) + + it("swaps a window with another", function() + given(function() + local windows = make_layout({ + "col", + { + "target", + { + "row", + { "leaf", "main" }, + }, + }, + }) + local main_win_id = windows["main"] + local target_win_id = windows["target"] + + assert.matches_winlayout(vim.fn.winlayout(), { + "col", + { + { "leaf", target_win_id }, + { + "row", + { + { "leaf" }, + { "leaf", main_win_id }, + }, + }, + }, + }) + + local bufnr1 = vim.api.nvim_win_get_buf(main_win_id) + local bufnr2 = vim.api.nvim_win_get_buf(target_win_id) + + winmove.swap_window(main_win_id) + winmove.swap_window(target_win_id) + + assert.matches_winlayout(vim.fn.winlayout(), { + "col", + { + { "leaf", target_win_id }, + { + "row", + { + { "leaf" }, + { "leaf", main_win_id }, + }, + }, + }, + }) + + assert.are.same(vim.api.nvim_win_get_buf(main_win_id), bufnr2) + assert.are.same(vim.api.nvim_win_get_buf(target_win_id), bufnr1) + end) + end) + + it("swaps a window with another across tabs", function() + given(function() + local main_win_id = make_layout({ + "col", + { + "leaf", + { + "row", + { "leaf", "main" }, + }, + }, + })["main"] + + assert.matches_winlayout(vim.fn.winlayout(), { + "col", + { + { "leaf" }, + { + "row", + { + { "leaf" }, + { "leaf", main_win_id }, + }, + }, + }, + }) + + vim.cmd.tabnew() + + local target_win_id = make_layout({ + "row", + { + "target", + { + "col", + { "leaf", "leaf" }, + }, + }, + })["target"] + + assert.matches_winlayout(vim.fn.winlayout(), { + "row", + { + { "leaf", target_win_id }, + { + "col", + { + { "leaf" }, + { "leaf" }, + }, + }, + }, + }) + + local bufnr1 = vim.api.nvim_win_get_buf(main_win_id) + local bufnr2 = vim.api.nvim_win_get_buf(target_win_id) + + winmove.swap_window(main_win_id) + winmove.swap_window(target_win_id) + + assert.are.same(vim.api.nvim_win_get_buf(main_win_id), bufnr2) + assert.are.same(vim.api.nvim_win_get_buf(target_win_id), bufnr1) + end) + end) + + it("fails if previously selected window is not valid anymore", function() + given(function() + stub(vim, "notify") + + local windows = make_layout({ + "col", + { + "target", + { + "row", + { "leaf", "main" }, + }, + }, + }) + local main_win_id = windows["main"] + local target_win_id = windows["target"] + + assert.matches_winlayout(vim.fn.winlayout(), { + "col", + { + { "leaf", target_win_id }, + { + "row", + { + { "leaf" }, + { "leaf", main_win_id }, + }, + }, + }, + }) + + local bufnr1 = vim.api.nvim_win_get_buf(target_win_id) + + winmove.swap_window(main_win_id) + vim.api.nvim_win_close(main_win_id, true) + winmove.swap_window(target_win_id) + + assert.stub(vim.notify).was.called_with( + "[winmove.nvim]: Previously selected window is not valid anymore", + vim.log.levels.ERROR + ) + + assert.matches_winlayout(vim.fn.winlayout(), { + "col", + { + { "leaf", target_win_id }, + { "leaf" }, + }, + }) + + assert.are.same(vim.api.nvim_win_get_buf(target_win_id), bufnr1) + + ---@diagnostic disable-next-line: undefined-field + vim.notify:revert() + end) + end) + + it("fails if swapping selected window with itself", function() + given(function() + stub(vim, "notify") + + local main_win_id = make_layout("main")["main"] + + assert.matches_winlayout(vim.fn.winlayout(), { "leaf", main_win_id }) + + winmove.swap_window(main_win_id) + winmove.swap_window(main_win_id) + + assert + .stub(vim.notify).was + .called_with("[winmove.nvim]: Cannot swap selected window with itself", vim.log.levels.ERROR) + + assert.matches_winlayout(vim.fn.winlayout(), { "leaf", main_win_id }) + + ---@diagnostic disable-next-line: undefined-field + vim.notify:revert() + end) + end) +end) diff --git a/tests/toggle_mode_spec.lua b/tests/toggle_mode_spec.lua new file mode 100644 index 0000000..1829b8e --- /dev/null +++ b/tests/toggle_mode_spec.lua @@ -0,0 +1,42 @@ +local winmove = require("winmove") +local config = require("winmove.config") +local vader = require("winmove.util.vader") +local test_helpers = require("winmove.util.test_helpers") + +local given = vader.given +local make_layout = test_helpers.make_layout + +describe("toggle mode", function() + it("toggles modes", function() + given(function() + ---@diagnostic disable-next-line: missing-fields + config.configure({ keymaps = { toggle_mode = "t" } }) + + local win_id = make_layout({ + "row", + { "main", "leaf" }, + })["main"] + + assert.matches_winlayout(vim.fn.winlayout(), { + "row", + { + { "leaf", win_id }, + { "leaf" }, + }, + }) + + vim.api.nvim_set_current_win(win_id) + winmove.start_mode(winmove.Mode.Move) + + assert.are.same(winmove.current_mode(), winmove.Mode.Move) + + vim.cmd([[execute "normal t"]]) + assert.are.same(winmove.current_mode(), winmove.Mode.Swap) + + vim.cmd([[execute "normal t"]]) + assert.are.same(winmove.current_mode(), winmove.Mode.Move) + + winmove.stop_mode() + end) + end) +end) diff --git a/tests/wrap_around_spec.lua b/tests/wrap_around_move_spec.lua similarity index 79% rename from tests/wrap_around_spec.lua rename to tests/wrap_around_move_spec.lua index 90cb78c..67e3fc5 100644 --- a/tests/wrap_around_spec.lua +++ b/tests/wrap_around_move_spec.lua @@ -12,16 +12,12 @@ describe("wrap-around when moving windows", function() it("wraps around when enabled in the config", function() config.configure({ - at_edge = { - horizontal = at_edge.Wrap, - vertical = at_edge.Wrap, - }, - keymaps = { + modes = { move = { - split_left = "sh", - split_down = "sj", - split_up = "sk", - split_right = "sl", + at_edge = { + horizontal = at_edge.AtEdge.Wrap, + vertical = at_edge.AtEdge.Wrap, + }, }, }, }) @@ -42,7 +38,6 @@ describe("wrap-around when moving windows", function() }, }) - vim.api.nvim_set_current_win(main_win_id) winmove.move_window(main_win_id, "h") assert.matches_winlayout(vim.fn.winlayout(), { @@ -57,16 +52,12 @@ describe("wrap-around when moving windows", function() it("does not wrap around when disabled in the config", function() config.configure({ - at_edge = { - horizontal = false, - vertical = false, - }, - keymaps = { + modes = { move = { - split_left = "sh", - split_down = "sj", - split_up = "sk", - split_right = "sl", + at_edge = { + horizontal = at_edge.AtEdge.None, + vertical = at_edge.AtEdge.None, + }, }, }, }) @@ -100,16 +91,12 @@ describe("wrap-around when moving windows", function() it("does not wrap full width window", function() config.configure({ - at_edge = { - horizontal = at_edge.Wrap, - vertical = at_edge.Wrap, - }, - keymaps = { + modes = { move = { - split_left = "sh", - split_down = "sj", - split_up = "sk", - split_right = "sl", + at_edge = { + horizontal = at_edge.AtEdge.Wrap, + vertical = at_edge.AtEdge.Wrap, + }, }, }, }) @@ -162,16 +149,12 @@ describe("wrap-around when moving windows", function() it("does not wrap full height window", function() config.configure({ - at_edge = { - horizontal = at_edge.Wrap, - vertical = at_edge.Wrap, - }, - keymaps = { + modes = { move = { - split_left = "sh", - split_down = "sj", - split_up = "sk", - split_right = "sl", + at_edge = { + horizontal = at_edge.AtEdge.Wrap, + vertical = at_edge.AtEdge.Wrap, + }, }, }, }) diff --git a/tests/wrap_around_swap_spec.lua b/tests/wrap_around_swap_spec.lua new file mode 100644 index 0000000..4d8c293 --- /dev/null +++ b/tests/wrap_around_swap_spec.lua @@ -0,0 +1,224 @@ +local winmove = require("winmove") +local at_edge = require("winmove.at_edge") +local config = require("winmove.config") +local vader = require("winmove.util.vader") +local test_helpers = require("winmove.util.test_helpers") + +local given = vader.given +local make_layout = test_helpers.make_layout + +describe("wrap-around when swapping windows", function() + assert:set_parameter("TableFormatLevel", 10) + + it("wraps around when enabled in the config", function() + config.configure({ + modes = { + swap = { + at_edge = { + horizontal = at_edge.AtEdge.Wrap, + vertical = at_edge.AtEdge.Wrap, + }, + }, + }, + }) + + given(function() + local win_ids = make_layout({ + "row", + { "main", "target" }, + }) + + local main_win_id = win_ids["main"] + local target_win_id = win_ids["target"] + local bufnr1 = vim.api.nvim_win_get_buf(main_win_id) + local bufnr2 = vim.api.nvim_win_get_buf(target_win_id) + + assert.matches_winlayout(vim.fn.winlayout(), { + "row", + { + { "leaf", main_win_id }, + { "leaf", target_win_id }, + }, + }) + + winmove.swap_window_in_direction(main_win_id, "h") + + assert.matches_winlayout(vim.fn.winlayout(), { + "row", + { + { "leaf", main_win_id }, + { "leaf", target_win_id }, + }, + }) + + assert.are.same(vim.api.nvim_win_get_buf(main_win_id), bufnr2) + assert.are.same(vim.api.nvim_win_get_buf(target_win_id), bufnr1) + end) + end) + + it("does not wrap around when disabled in the config", function() + config.configure({ + modes = { + swap = { + at_edge = { + horizontal = at_edge.AtEdge.None, + vertical = at_edge.AtEdge.None, + }, + }, + }, + }) + + given(function() + local win_ids = make_layout({ + "row", + { "main", "target" }, + }) + + local main_win_id = win_ids["main"] + local target_win_id = win_ids["target"] + local bufnr1 = vim.api.nvim_win_get_buf(main_win_id) + local bufnr2 = vim.api.nvim_win_get_buf(target_win_id) + + assert.matches_winlayout(vim.fn.winlayout(), { + "row", + { + { "leaf", main_win_id }, + { "leaf", target_win_id }, + }, + }) + + winmove.swap_window_in_direction(main_win_id, "h") + + assert.matches_winlayout(vim.fn.winlayout(), { + "row", + { + { "leaf", main_win_id }, + { "leaf", target_win_id }, + }, + }) + + assert.are.same(vim.api.nvim_win_get_buf(main_win_id), bufnr1) + assert.are.same(vim.api.nvim_win_get_buf(target_win_id), bufnr2) + end) + end) + + it("does not wrap full width window", function() + config.configure({ + modes = { + swap = { + at_edge = { + horizontal = at_edge.AtEdge.Wrap, + vertical = at_edge.AtEdge.Wrap, + }, + }, + }, + }) + + given(function() + local main_win_id = make_layout({ + "col", + { + "main", + { + "row", + { "leaf", "leaf" }, + }, + }, + })["main"] + + assert.matches_winlayout(vim.fn.winlayout(), { + "col", + { + { "leaf", main_win_id }, + { + "row", + { + { "leaf" }, + { "leaf" }, + }, + }, + }, + }) + + local bufnr = vim.api.nvim_win_get_buf(main_win_id) + winmove.swap_window_in_direction(main_win_id, "h") + winmove.swap_window_in_direction(main_win_id, "l") + + assert.matches_winlayout(vim.fn.winlayout(), { + "col", + { + { "leaf", main_win_id }, + { + "row", + { + { "leaf" }, + { "leaf" }, + }, + }, + }, + }) + + assert.are.same(vim.api.nvim_win_get_buf(main_win_id), bufnr) + end) + end) + + it("does not wrap full height window", function() + config.configure({ + modes = { + swap = { + at_edge = { + horizontal = at_edge.AtEdge.Wrap, + vertical = at_edge.AtEdge.Wrap, + }, + }, + }, + }) + + given(function() + local main_win_id = make_layout({ + "row", + { + "main", + { + "col", + { "leaf", "leaf" }, + }, + }, + })["main"] + + assert.matches_winlayout(vim.fn.winlayout(), { + "row", + { + { "leaf", main_win_id }, + { + "col", + { + { "leaf" }, + { "leaf" }, + }, + }, + }, + }) + + local bufnr = vim.api.nvim_win_get_buf(main_win_id) + winmove.swap_window_in_direction(main_win_id, "k") + winmove.swap_window_in_direction(main_win_id, "j") + + assert.matches_winlayout(vim.fn.winlayout(), { + "row", + { + { "leaf", main_win_id }, + { + "col", + { + { "leaf" }, + { "leaf" }, + }, + }, + }, + }) + + assert.are.same(vim.api.nvim_win_get_buf(main_win_id), bufnr) + end) + end) +end)