Skip to content

Commit e892c07

Browse files
initial commit
1 parent 688b6ef commit e892c07

6 files changed

Lines changed: 476 additions & 0 deletions

File tree

.stylua.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
column_width = 80
2+
line_endings = "Unix"
3+
indent_type = "Spaces"
4+
indent_width = 4
5+
quote_style = "AutoPreferDouble"

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.PHONY: test
2+
3+
test:
4+
nvim --headless -c "PlenaryBustedDirectory lua/tests/ { minimal_init = './scripts/minimal_init.lua', file_pattern = '*_spec.lua' }" -c "qa"
5+

README.md

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# 🛗 elevator.nvim
2+
3+
**Context-aware keymaps for Neovim.**
4+
Think of it as an *elevator*: you move between “floors” (contexts), and your keymaps change automatically depending on where you are.
5+
6+
---
7+
8+
## ✨ Features
9+
10+
- 🔑 Override existing keymaps **only when relevant**
11+
- 🎯 Contexts are defined with a `match` function and one or more events
12+
- ⏫ Supports **priority** (decide which context wins when multiple apply)
13+
- ♻️ Contexts can be added or removed **at runtime**
14+
- 🧩 Perfect for plugins that need temporary keymaps without conflicts
15+
16+
---
17+
18+
## 📦 Installation
19+
20+
Using **lazy.nvim**:
21+
22+
```lua
23+
{
24+
"StackInTheWild/elevator.nvim",
25+
config = function()
26+
require("elevator").setup()
27+
end,
28+
}
29+
```
30+
31+
---
32+
33+
## 🛠️ Usage
34+
35+
### Define a context
36+
37+
```lua
38+
local elevator = require("elevator")
39+
40+
elevator.add_context("git_conflict", {
41+
events = { "CursorMoved", "BufEnter" },
42+
priority = 80,
43+
match = function()
44+
local line = vim.api.nvim_get_current_line()
45+
return line:match("^<<<<<<<") or line:match("^=======") or line:match("^>>>>>>>")
46+
end,
47+
mappings = {
48+
n = {
49+
["]x"] = "<cmd>HeadhunterNext<cr>",
50+
["[x"] = "<cmd>HeadhunterPrevious<cr>",
51+
["co"] = "<cmd>HeadhunterTakeHead<cr>",
52+
["ci"] = "<cmd>HeadhunterTakeOrigin<cr>",
53+
["cb"] = "<cmd>HeadhunterTakeBoth<cr>",
54+
},
55+
},
56+
})
57+
```
58+
59+
👉 When your cursor is inside a Git conflict, these keymaps override your normal ones.
60+
Move out of the conflict → your original keymaps come back automatically.
61+
62+
---
63+
64+
## 💡 More Examples
65+
66+
### 🐞 Debugging with nvim-dap
67+
68+
```lua
69+
elevator.add_context("dap", {
70+
events = { "User" }, -- listens to User autocommands from nvim-dap
71+
priority = 90,
72+
match = function()
73+
return vim.g.in_debug_session == true
74+
end,
75+
mappings = {
76+
n = {
77+
["<F5>"] = "<cmd>DapContinue<cr>",
78+
["<F10>"] = "<cmd>DapStepOver<cr>",
79+
["<F11>"] = "<cmd>DapStepInto<cr>",
80+
["<F12>"] = "<cmd>DapStepOut<cr>",
81+
},
82+
},
83+
})
84+
```
85+
86+
➡️ Debug keymaps only exist **while debugging**. No pollution outside.
87+
88+
---
89+
90+
### 📜 Markdown Editing
91+
92+
```lua
93+
elevator.add_context("markdown", {
94+
events = { "BufEnter" },
95+
priority = 10,
96+
match = function()
97+
return vim.bo.filetype == "markdown"
98+
end,
99+
mappings = {
100+
n = {
101+
["<leader>p"] = "<cmd>MarkdownPreviewToggle<cr>",
102+
},
103+
i = {
104+
["<C-b>"] = "****<Esc>F*i",
105+
},
106+
},
107+
})
108+
```
109+
110+
➡️ Editing Markdown gets special bindings, without touching other filetypes.
111+
112+
---
113+
114+
### 🔍 Telescope Inside Search
115+
116+
```lua
117+
elevator.add_context("telescope", {
118+
events = { "BufEnter" },
119+
priority = 100,
120+
match = function()
121+
return vim.bo.filetype == "TelescopePrompt"
122+
end,
123+
mappings = {
124+
i = {
125+
["<C-j>"] = "move_selection_next",
126+
["<C-k>"] = "move_selection_previous",
127+
},
128+
},
129+
})
130+
```
131+
132+
➡️ Inside Telescope prompt, you override `<C-j>/<C-k>` just for navigation.
133+
134+
---
135+
136+
## ⚙️ Events
137+
138+
`events` are the Neovim autocommands that trigger re-checking a context.
139+
Common choices:
140+
141+
- **BufEnter** → whenever you enter a buffer (great for filetype-specific contexts)
142+
- **CursorMoved** → whenever you move around (great for “cursor is inside X” checks)
143+
- **User** → custom plugin signals. For example:
144+
- `nvim-dap` fires `User DapStarted`, `User DapStopped`, `User DapTerminated`
145+
- You can listen for these to toggle debug keymaps
146+
147+
👉 Pick the event(s) that best signal “my context might have changed.”
148+
149+
---
150+
151+
## 🔧 API
152+
153+
- `require("elevator").setup(opts?)` → initialize plugin
154+
- `add_context(name, ctx)` → add a context at runtime
155+
- `remove_context(name)` → unregister a context
156+
- `contexts` → table of registered contexts
157+
- `active` → currently active contexts
158+
- `current_floor` → the one with highest priority right now
159+
160+
---
161+
162+
## ✅ Why?
163+
164+
Without elevator.nvim:
165+
- Debug keymaps, Git conflict maps, or plugin shortcuts are always present
166+
- They pollute your global keymap space
167+
- They may conflict with your own bindings
168+
169+
With elevator.nvim:
170+
- Keymaps only exist **when relevant**
171+
- Conflicts vanish — `<F5>` can mean “run tests” normally and “continue debug” during debugging
172+
- Your keyboard stays clean and consistent
173+
174+
---
175+
176+
## 📜 License
177+
178+
MIT

lua/elevator/init.lua

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
local M = {}
2+
3+
M.contexts = {}
4+
M.active = {}
5+
M.current_floor = nil
6+
7+
-- =========================================================
8+
-- UTIL
9+
-- =========================================================
10+
local function tbl_contains(tbl, val)
11+
for _, v in ipairs(tbl) do
12+
if v == val then
13+
return true
14+
end
15+
end
16+
return false
17+
end
18+
19+
-- =========================================================
20+
-- CONTEXT MANAGEMENT
21+
-- =========================================================
22+
function M.add_context(name, ctx)
23+
M.contexts[name] = ctx
24+
local group = vim.api.nvim_create_augroup("Elevator", { clear = false })
25+
26+
for _, event in ipairs(ctx.events or {}) do
27+
vim.api.nvim_create_autocmd(event, {
28+
group = group,
29+
callback = function()
30+
M.check_context(name, ctx)
31+
end,
32+
})
33+
end
34+
end
35+
36+
function M.remove_context(name)
37+
if M.contexts[name] then
38+
M.contexts[name] = nil
39+
M.active[name] = nil
40+
if M.current_floor == name then
41+
M.current_floor = nil
42+
M.resolve_floor()
43+
end
44+
end
45+
end
46+
47+
-- =========================================================
48+
-- FLOOR RESOLUTION
49+
-- =========================================================
50+
function M.check_context(name, context)
51+
local is_active = context.match and context.match() or false
52+
53+
-- Activate keymaps if the context matches and is not already active
54+
if is_active and not context._active then
55+
for mode, map in pairs(context.mappings or {}) do
56+
for lhs, rhs in pairs(map) do
57+
vim.keymap.set(
58+
mode,
59+
lhs,
60+
rhs,
61+
{ noremap = true, silent = true }
62+
)
63+
end
64+
end
65+
context._active = true
66+
M.active[name] = context
67+
-- update current_floor if higher priority
68+
if
69+
not M.current_floor
70+
or context.priority
71+
> (M.contexts[M.current_floor] and M.contexts[M.current_floor].priority or 0)
72+
then
73+
M.current_floor = name
74+
end
75+
76+
-- Deactivate keymaps if the context no longer matches but was active
77+
elseif context._active and not is_active then
78+
for mode, map in pairs(context.mappings or {}) do
79+
for lhs, _ in pairs(map) do
80+
vim.keymap.del(mode, lhs)
81+
end
82+
end
83+
context._active = false
84+
M.active[name] = nil
85+
86+
-- recalc current_floor if needed
87+
M.current_floor = nil
88+
for floor_name, ctx in pairs(M.active) do
89+
if
90+
not M.current_floor
91+
or ctx.priority > M.active[M.current_floor].priority
92+
then
93+
M.current_floor = floor_name
94+
end
95+
end
96+
end
97+
end
98+
99+
function M.resolve_floor()
100+
local top, top_prio = nil, -math.huge
101+
for name, _ in pairs(M.active) do
102+
local ctx = M.contexts[name]
103+
if ctx.priority > top_prio then
104+
top, top_prio = name, ctx.priority
105+
end
106+
end
107+
108+
if top ~= M.current_floor then
109+
M.swap_mappings(M.current_floor, top)
110+
M.current_floor = top
111+
end
112+
end
113+
114+
-- =========================================================
115+
-- MAPPINGS
116+
-- =========================================================
117+
function M.swap_mappings(old, new)
118+
-- restore old
119+
if old then
120+
local ctx = M.contexts[old]
121+
if ctx and ctx.mappings then
122+
for mode, maps in pairs(ctx.mappings) do
123+
for lhs, _ in pairs(maps) do
124+
pcall(vim.keymap.del, mode, lhs, { buffer = 0 })
125+
end
126+
end
127+
end
128+
end
129+
130+
-- apply new
131+
if new then
132+
local ctx = M.contexts[new]
133+
if ctx and ctx.mappings then
134+
for mode, maps in pairs(ctx.mappings) do
135+
for lhs, rhs in pairs(maps) do
136+
vim.keymap.set(mode, lhs, rhs, { buffer = 0 })
137+
end
138+
end
139+
end
140+
end
141+
end
142+
143+
-- =========================================================
144+
-- PUBLIC API
145+
-- =========================================================
146+
function M.setup(opts)
147+
opts = opts or {}
148+
M.contexts = {}
149+
150+
-- clear previous autocmds
151+
vim.api.nvim_create_augroup("Elevator", { clear = true })
152+
153+
for name, ctx in pairs(opts.contexts or {}) do
154+
M.add_context(name, ctx)
155+
end
156+
157+
-- user commands
158+
vim.api.nvim_create_user_command("ElevatorAddContext", function(args)
159+
local ctx = load("return " .. args.args)()
160+
M.add_context(ctx.name, ctx)
161+
end, { nargs = 1 })
162+
163+
vim.api.nvim_create_user_command("ElevatorRemoveContext", function(args)
164+
M.remove_context(args.args)
165+
end, { nargs = 1 })
166+
167+
vim.api.nvim_create_user_command("ElevatorFloors", function()
168+
print("Current floor: " .. (M.current_floor or "none"))
169+
print("Active contexts:")
170+
for name, _ in pairs(M.active) do
171+
print(" - " .. name)
172+
end
173+
end, {})
174+
end
175+
176+
function M.clear()
177+
M.contexts = {}
178+
M.active = {}
179+
M.current_floor = nil
180+
end
181+
182+
-- =========================================================
183+
-- STATUSLINE HELPER
184+
-- =========================================================
185+
function M.statusline()
186+
return M.current_floor and ("[Elevator:" .. M.current_floor .. "]") or ""
187+
end
188+
189+
return M

0 commit comments

Comments
 (0)