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
4 changes: 3 additions & 1 deletion defaults/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,9 @@ The main ones:
- Panes: `pane.split {direction: "right" | "down", pane?, argv? | mod?, props?}` (a new
terminal, or a mod pane), `pane.close {pane?}`,
`pane.zoom {pane?}`, `pane.focus {pane | terminal}`, `pane.go {direction}`,
`pane.swap {pane?, direction | target}`.
`pane.swap {pane?, direction | target}`, `pane.resize {direction, pane?, px?}` (moves
the nearest border along that axis by px, default 40, as a drag would; a keyboard
resize mode is a mod that runs it on h/j/k/l).
`pane.zoom` also makes the pane its tab's focused one (the tab isn't
selected); on the zoomed pane it unzooms and focus stays.
`pane.go` prefers the most recently focused pane along the nearest edge in that
Expand Down
2 changes: 1 addition & 1 deletion defaults/herdr.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function playSound(sound: string): null;
* New spaces and tabs take focus by default. Boolean focus: false keeps the shown
* space, selected tab and keyboard focus; background tabs are appended in creation order.
* With no space shown, a new space is shown even with focus: false.
* Panes: pane.split {direction: "right" | "down", pane?}, pane.close {pane?}, pane.zoom {pane?},
* Panes: pane.split {direction: "right" | "down", pane?}, pane.close {pane?}, pane.zoom {pane?}, pane.resize {direction, pane?, px?},
* pane.focus {pane | terminal}, pane.go {direction}, pane.swap {pane?, direction | target}.
* Pane menus get target { pane, terminal }.
* Terminals: terminal.open {argv?, folder?, place?: "tab" | "right" | "down"}, terminal.kill {terminal},
Expand Down
10 changes: 10 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,16 @@ Focus the pane or focusable mod to the "left", "right", "up" or "down".
|---|---|
| `direction` | yes |

## `pane.resize`

Move the border of a pane (default: the focused one) "left", "right", "up" or "down" by px (default 40), like dragging the divider.

| Argument | Required |
|---|---|
| `direction` | yes |
| `pane` | no |
| `px` | no |

## `pane.swap`

Swap a pane (default: the focused one) with its neighbor in a direction, or with another pane of its tab (target). Give exactly one of direction or target.
Expand Down
1 change: 1 addition & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ pub const COMMANDS: &[Command] = &[
cmd("pane.zoom", &[opt("pane")], "Show only this pane of its tab, or all again."),
cmd("pane.focus", &[opt("pane"), opt("terminal")], "Focus a pane (or the pane showing a terminal)."),
cmd("pane.go", &[req("direction")], "Focus the pane or focusable mod to the \"left\", \"right\", \"up\" or \"down\"."),
cmd("pane.resize", &[req("direction"), opt("pane"), opt("px")], "Move the border of a pane (default: the focused one) \"left\", \"right\", \"up\" or \"down\" by px (default 40), like dragging the divider."),
cmd("pane.swap", &[opt("direction"), opt("pane"), opt("target")], "Swap a pane (default: the focused one) with its neighbor in a direction, or with another pane of its tab (target). Give exactly one of direction or target."),
cmd("terminal.peek", &[req("terminal")], "Peek at a running terminal over the tab, with keep-as-tab and close controls."),
cmd("terminal.show", &[opt("terminal"), opt("pane")], "Show a terminal in a pane for now, in place of its own (no terminal: back)."),
Expand Down
92 changes: 91 additions & 1 deletion src/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3195,6 +3195,17 @@ impl AppWindow {
}
return self.go(dir, window, cx);
}
"pane.resize" => {
let dir = Self::owned(a, "direction").unwrap_or_default();
let amount = a.get("px").and_then(Value::as_f64).unwrap_or(40.0) as f32;
let Some(pane) = Self::owned(a, "pane").or_else(|| self.model.focused_pane()) else {
return self.fail("no pane to resize");
};
if let Err(e) = self.resize_pane(&pane, &dir, amount) {
return self.fail(e);
}
self.save();
}
"pane.swap" => {
let by = match swap_by(a) {
Ok(by) => by,
Expand Down Expand Up @@ -3672,6 +3683,60 @@ impl AppWindow {
self.copies.get(id).and_then(|c| self.folders.get(&c.inst.name)).is_some_and(|f| f.hint.focusable)
}

/// Moves the divider nearest a pane along `dir`'s axis, as a mouse drag would.
/// The border moves in `dir` whichever side of it the pane is on (like tmux).
fn resize_pane(&mut self, pane: &str, dir: &str, amount: f32) -> Result<(), String> {
let (axis, sign) = match dir {
"left" => (Axis::Row, -1.0),
"right" => (Axis::Row, 1.0),
"up" => (Axis::Column, -1.0),
"down" => (Axis::Column, 1.0),
_ => return Err(format!("direction must be left, right, up or down, not {dir:?}")),
};
let found = {
let tab = self.model.tab_of(pane).ok_or_else(|| format!("no pane {pane:?}"))?;
if tab.zoomed.is_some() {
return Ok(());
}
let root = &tab.root;
let path = root.path_to(pane).ok_or_else(|| format!("no pane {pane:?}"))?;
let mut found = None;
for i in (0..path.len().saturating_sub(1)).rev() {
let Some(node) = root.find(&path[i]) else { continue };
if node.axis() != Some(axis) || !node.resizable() {
continue;
}
let shown: Vec<&Node> = node.children().iter().filter(|c| !c.hidden).collect();
if shown.len() < 2 {
continue;
}
let Some(idx) = shown.iter().position(|c| c.id == path[i + 1]) else { continue };
let index = if sign > 0.0 {
if idx + 1 < shown.len() { idx } else { idx - 1 }
} else if idx > 0 {
idx - 1
} else {
idx
};
found = Some((node.id.clone(), index, shown[index].id.clone()));
break;
}
found
};
// Nothing split that way: a no-op, like a keyboard resize in a terminal multiplexer.
let Some((container, index, a_id)) = found else { return Ok(()) };
let b = self.bounds.borrow().get(&a_id).copied().ok_or("that pane isn't on screen")?;
let delta = px(sign * amount * settings::zoom());
let pos = match axis {
Axis::Row => point(b.right() + delta, b.top()),
Axis::Column => point(b.left(), b.bottom() + delta),
};
self.drag = Some(Drag { container, index, axis });
self.drag_to(pos);
self.drag = None;
Ok(())
}

fn swap_pane(&mut self, from: &str, explicit: bool, dir: &str) -> Result<(), String> {
let neighbor = self.neighbor(from, dir, false);
swap_with_neighbor(&mut self.model, from, explicit, neighbor)
Expand Down Expand Up @@ -4947,7 +5012,7 @@ fn swap_by(args: &Value) -> Result<SwapBy<'_>, String> {

fn needs_model_pane(name: &str, args: &Value) -> bool {
match name {
"pane.split" | "pane.close" | "pane.zoom" | "pane.swap" | "terminal.show" => AppWindow::arg(args, "pane").is_none(),
"pane.split" | "pane.close" | "pane.zoom" | "pane.swap" | "pane.resize" | "terminal.show" => AppWindow::arg(args, "pane").is_none(),
"pane.go" => true,
"terminal.open" | "terminal.keep" => matches!(AppWindow::arg(args, "place"), Some("right" | "down")),
_ => false,
Expand Down Expand Up @@ -6494,6 +6559,31 @@ mod tests {
}));
}

#[test]
fn panes_resize_from_the_keyboard() {
in_own_process("pane_resize_checks_direction");
}

#[gpui::test]
fn pane_resize_checks_direction(cx: &mut gpui::TestAppContext) {
use serde_json::json;
if !running_alone("pane_resize_checks_direction") {
return;
}
let (view, cx) = open_window(cx, "unused", "", json!({
"id": "root", "type": "block", "content": { "tab": "active" }
}));
cx.update(|window, cx| view.update(cx, |this, cx| {
this.model.add_space("sp1", "", "/tmp", crate::model::Tab::new("tb1", "", "p1", "t1"));
this.sync(window, cx);
let r = this.run_as("pane.resize", json!({ "pane": "p1", "direction": "sideways" }), false, window, cx);
assert!(r.get("error").is_some(), "{r}");
let r = this.run_as("pane.resize", json!({ "pane": "p1", "direction": "left" }), false, window, cx);
assert!(r.get("error").is_none(), "a pane with nothing beside it: nothing to move, {r}");
assert!(this.run_as("pane.resize", json!({ "pane": "nope", "direction": "left" }), false, window, cx).get("error").is_some());
}));
}

#[test]
fn pane_ring_follows_keyboard_focus() {
in_own_process("pane_ring_focus");
Expand Down
Loading