Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,49 @@ return {

## Lazy.nvim

### Basic setup

```lua
{
"devxplay/herdr.nvim",
}
```

### LazyVim / NvChad / Distro compatibility

LazyVim and similar Neovim distributions set default `Ctrl+h/j/k/l` keymaps for window navigation that load after plugins. To ensure herdr.nvim's keymaps take precedence, use this configuration:

```lua
{
"devxplay/herdr.nvim",
lazy = false,
priority = 1000,
config = function()
require("herdr").setup({
set_keymaps = false, -- We'll set them after LazyVim loads
})

-- Set keymaps after LazyVim's defaults
vim.api.nvim_create_autocmd("VimEnter", {
callback = function()
vim.defer_fn(function()
vim.keymap.set("n", "<C-h>", function() require("herdr.navigator").navigate("left") end, { desc = "Navigate left" })
vim.keymap.set("n", "<C-j>", function() require("herdr.navigator").navigate("down") end, { desc = "Navigate down" })
vim.keymap.set("n", "<C-k>", function() require("herdr.navigator").navigate("up") end, { desc = "Navigate up" })
vim.keymap.set("n", "<C-l>", function() require("herdr.navigator").navigate("right") end, { desc = "Navigate right" })
end, 100)
end,
})
end,
}
```

This configuration:
- Loads the plugin immediately (`lazy = false`)
- Loads early with high priority (`priority = 1000`)
- Waits for Neovim to finish starting, then sets the keymaps after a short delay
- Ensures herdr.nvim's navigation keymaps override the distribution's defaults

## Herdr config

Install `herdr-navigator` somewhere on your `PATH` for Herdr shell keybindings:
Expand Down
16 changes: 15 additions & 1 deletion lua/herdr/navigator.lua
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,21 @@ local function run_helper(args)
end

local function pane_id()
return vim.env.HERDR_ACTIVE_PANE_ID or vim.env.HERDR_PANE_ID
local id = vim.env.HERDR_ACTIVE_PANE_ID or vim.env.HERDR_PANE_ID
if id and id ~= "" then
return id
end

local handle = io.popen("herdr pane current 2>/dev/null")
if handle then
local result = handle:read("*a")
handle:close()
local json = vim.json and vim.json.decode(result) or vim.fn.json_decode(result)
if json and json.result and json.result.pane then
return json.result.pane.pane_id
end
end
return nil
end

local function nvim_panes_dir()
Expand Down
55 changes: 41 additions & 14 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,16 +205,21 @@ fn active_workspace_and_tab() -> Result<(Value, Value), String> {
Ok((workspace, tab))
}

fn pane_public_mapping(workspace: &Value, tab: &Value) -> Result<BTreeMap<i64, String>, String> {
fn pane_public_mapping(workspace: &Value, _tab: &Value) -> Result<BTreeMap<i64, String>, String> {
let workspace_id = workspace
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| "active Herdr workspace has no id".to_string())?;
let panes = tab
let panes = _tab
.get("panes")
.and_then(Value::as_object)
.ok_or_else(|| "active Herdr tab has no panes object".to_string())?;

let public_pane_numbers = workspace
.get("public_pane_numbers")
.and_then(Value::as_object)
.ok_or_else(|| "active Herdr workspace has no public_pane_numbers".to_string())?;

let mut internal_ids = panes
.keys()
.filter_map(|pane_id| pane_id.parse::<i64>().ok())
Expand All @@ -223,8 +228,13 @@ fn pane_public_mapping(workspace: &Value, tab: &Value) -> Result<BTreeMap<i64, S

Ok(internal_ids
.into_iter()
.enumerate()
.map(|(index, internal_id)| (internal_id, format!("{workspace_id}-{}", index + 1)))
.map(|internal_id| {
let public_number = public_pane_numbers
.get(&internal_id.to_string())
.and_then(Value::as_u64)
.unwrap_or(internal_id as u64 + 1);
(internal_id, format!("{workspace_id}:p{public_number}"))
})
.collect())
}

Expand Down Expand Up @@ -433,6 +443,17 @@ fn adjacent_public(
candidates.first().map(|candidate| candidate.2.clone())
}

fn focus_pane_direction(pane_id: &str, direction: &str) -> Result<(), String> {
result(
"pane.focus_direction",
json!({
"pane_id": pane_id,
"direction": direction,
}),
)?;
Ok(())
}

fn focus_pane(pane_id: &str) -> Result<(), String> {
let seq = now_ns();
let normalized = pane_id.replace(['-', ':'], "_");
Expand Down Expand Up @@ -578,6 +599,12 @@ fn focus_adjacent(direction: &str, current_pane_id: Option<String>) -> Result<i3
return Ok(1);
};

// Try the new pane.focus_direction API first (herdr 0.7.0+)
if focus_pane_direction(&current_pane_id, direction).is_ok() {
return Ok(0);
}

// Fall back to computed approach for older herdr versions
if let Some(target_public) = cached_adjacent(direction, &current_pane_id)? {
focus_pane(&target_public)?;
return Ok(0);
Expand Down Expand Up @@ -646,25 +673,25 @@ fn pane_is_registered_nvim(pane_id: &str) -> Result<bool, String> {
Ok(dir.join(&public_pane_id).exists())
}

fn ctrl_text(direction: &str) -> Option<&'static str> {
fn ctrl_key(direction: &str) -> Option<&'static str> {
match direction {
"left" => Some("\u{0008}"),
"down" => Some("\u{000a}"),
"up" => Some("\u{000b}"),
"right" => Some("\u{000c}"),
"left" => Some("ctrl+h"),
"down" => Some("ctrl+j"),
"up" => Some("ctrl+k"),
"right" => Some("ctrl+l"),
_ => None,
}
}

fn send_ctrl_to_pane(pane_id: &str, direction: &str) -> Result<(), String> {
let Some(text) = ctrl_text(direction) else {
let Some(key) = ctrl_key(direction) else {
return Err(format!("unsupported direction {direction}"));
};
result(
"pane.send_text",
"pane.send_keys",
json!({
"pane_id": pane_id,
"text": text,
"keys": [key],
}),
)?;
Ok(())
Expand Down Expand Up @@ -825,13 +852,13 @@ fn run() -> Result<i32, String> {
match (command, direction) {
("register", _) => register(),
("release", _) => release(),
("focus", Some(direction)) if ctrl_text(direction).is_some() => {
("focus", Some(direction)) if ctrl_key(direction).is_some() => {
focus_adjacent(direction, None)
}
("split", Some(direction)) if matches!(direction, "right" | "down") => {
split_pane(direction)
}
("dispatch", Some(direction)) if ctrl_text(direction).is_some() => dispatch(direction),
("dispatch", Some(direction)) if ctrl_key(direction).is_some() => dispatch(direction),
_ => {
usage();
Ok(2)
Expand Down